Compare commits
17 Commits
issue-305-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9c70287c | |||
| f142289813 | |||
| c17ab41a33 | |||
| 3bf9363c34 | |||
| b1165a37e6 | |||
| 69563348ca | |||
| 7e17a2dba6 | |||
| c2a4dde5cc | |||
| 9cf7b85b93 | |||
| 64f2deb40f | |||
| 20677ea247 | |||
| 6999b3dd73 | |||
| 4d6a27194f | |||
| 9f754649d4 | |||
| d2be1116bc | |||
| 78258c4f9b | |||
| a327126fac |
@ -345,6 +345,16 @@ jobs:
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
|
||||
|
||||
- name: Reset login rate limit before admin-settings pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||
|
||||
- name: Run admin-settings pack
|
||||
run: |
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/admin-settings.spec.ts
|
||||
|
||||
- name: Reset login rate limit before admin-quotas pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
@ -365,6 +375,18 @@ jobs:
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
|
||||
|
||||
- name: Reset login rate limit before invitations pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||
|
||||
# Invitations (issue #332) need the mail catcher like the auth pack:
|
||||
# the invite link and the follow-up verification both travel by mail.
|
||||
- name: Run invitations pack
|
||||
run: |
|
||||
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/invitations.spec.ts
|
||||
|
||||
- name: Reset login rate limit before permission-matrix pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
-- Peer invitations (issue #332): a user invites an e-mail address; the token
|
||||
-- allows exactly one registration even while registration is closed.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "invitations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"inviter_id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"revoked_at" TIMESTAMP(3),
|
||||
"accepted_at" TIMESTAMP(3),
|
||||
"accepted_user_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "invitations_token_hash_key" ON "invitations"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "invitations_inviter_id_idx" ON "invitations"("inviter_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_inviter_id_fkey" FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -68,10 +68,34 @@ model User {
|
||||
notifications Notification[]
|
||||
favorites PageFavorite[]
|
||||
customFonts CustomFont[]
|
||||
invitations Invitation[] @relation("InvitationsSent")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
/// Peer invitations (issue #332): a user invites an e-mail address; the
|
||||
/// token allows exactly one registration even while registration is
|
||||
/// closed. Only the SHA-256 hash of the token is stored (auth-tokens
|
||||
/// pattern); revoked/accepted rows are kept so the settings UI can show
|
||||
/// history. "Open" (pending, unexpired) rows count against the per-user
|
||||
/// quota `invitations.maxOpenPerUser`.
|
||||
model Invitation {
|
||||
id String @id @default(uuid())
|
||||
inviterId String @map("inviter_id")
|
||||
email String
|
||||
tokenHash String @unique @map("token_hash")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
acceptedAt DateTime? @map("accepted_at")
|
||||
acceptedUserId String? @map("accepted_user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
inviter User @relation("InvitationsSent", fields: [inviterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([inviterId])
|
||||
@@map("invitations")
|
||||
}
|
||||
|
||||
/// Persistent audit trail (issue #86, security.md §Logging): auth events and
|
||||
/// admin actions — grants, member roles, plugin installs, quota and settings
|
||||
/// changes, setup steps, manual job triggers. Written by AuditService, which
|
||||
|
||||
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BackupModule } from '../backup/backup.module';
|
||||
import { PondsModule } from '../ponds/ponds.module';
|
||||
import { QuotasModule } from '../quotas/quotas.module';
|
||||
import { SchedulerModule } from '../scheduler/scheduler.module';
|
||||
import { SearchModule } from '../search/search.module';
|
||||
@ -19,7 +20,15 @@ import { UserAdminController } from './user-admin.controller';
|
||||
import { UserAdminService } from './user-admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule, BackupModule, SearchModule],
|
||||
imports: [
|
||||
QuotasModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
SchedulerModule,
|
||||
BackupModule,
|
||||
SearchModule,
|
||||
PondsModule,
|
||||
],
|
||||
controllers: [
|
||||
AdminSettingsController,
|
||||
BackupAdminController,
|
||||
|
||||
@ -12,9 +12,11 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AdminCreateUserInput,
|
||||
AdminUserListQuery,
|
||||
AdminUserListView,
|
||||
AdminUserView,
|
||||
adminCreateUserSchema,
|
||||
adminUserListQuerySchema,
|
||||
setSiteAdminSchema,
|
||||
setUserDisabledSchema,
|
||||
@ -31,6 +33,14 @@ import { UserAdminService } from './user-admin.service';
|
||||
export class UserAdminController {
|
||||
constructor(private readonly users: UserAdminService) {}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(adminCreateUserSchema)) input: AdminCreateUserInput,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<AdminUserView> {
|
||||
return this.users.createUser(request.user!, input);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(
|
||||
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,
|
||||
|
||||
@ -69,6 +69,64 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('creates an account that can log in right away, with a personal pond (issue #331)', async () => {
|
||||
const username = `ua-created-${suffix}`;
|
||||
const res = await api()
|
||||
.post('/api/v1/admin/users')
|
||||
.set('Cookie', cookies.admin1!)
|
||||
.send({
|
||||
username,
|
||||
email: `${username}@example.org`,
|
||||
displayName: 'UA Created',
|
||||
password,
|
||||
locale: 'de',
|
||||
})
|
||||
.expect(201);
|
||||
const created = res.body as { id: string; status: string };
|
||||
ids.created = created.id;
|
||||
// No verification hop: the admin vouched for the address.
|
||||
expect(created.status).toBe('ACTIVE');
|
||||
await api()
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ usernameOrEmail: username, password })
|
||||
.expect(200);
|
||||
// The personal pond exists exactly like after self-registration.
|
||||
expect(await prisma.pond.count({ where: { ownerId: created.id, type: 'PERSONAL' } })).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects duplicate usernames with a field-level conflict', async () => {
|
||||
await api()
|
||||
.post('/api/v1/admin/users')
|
||||
.set('Cookie', cookies.admin1!)
|
||||
.send({
|
||||
username: `ua-created-${suffix}`,
|
||||
email: `ua-created-other-${suffix}@example.org`,
|
||||
displayName: 'UA Dup',
|
||||
password,
|
||||
locale: 'en',
|
||||
})
|
||||
.expect(409)
|
||||
.expect((r) =>
|
||||
expect((r.body as { details: Record<string, string[]> }).details.username).toEqual([
|
||||
'validation.taken',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses creation for non-admins', async () => {
|
||||
await api()
|
||||
.post('/api/v1/admin/users')
|
||||
.set('Cookie', cookies.bob!)
|
||||
.send({
|
||||
username: `ua-sneak-${suffix}`,
|
||||
email: `ua-sneak-${suffix}@example.org`,
|
||||
displayName: 'UA Sneak',
|
||||
password,
|
||||
locale: 'en',
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('lists and searches users (Site-Admin only)', async () => {
|
||||
const res = await api()
|
||||
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
AdminCreateUserInput,
|
||||
AdminUserListQuery,
|
||||
AdminUserListView,
|
||||
AdminUserStatus,
|
||||
@ -10,7 +11,9 @@ import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PondsService } from '../ponds/ponds.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { PseudonymizationService } from './pseudonymization.service';
|
||||
|
||||
/**
|
||||
@ -27,12 +30,33 @@ export class UserAdminService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pseudonymizer: PseudonymizationService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly users: UsersService,
|
||||
private readonly ponds: PondsService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(UserAdminService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an account on behalf of a user (issue #331). The e-mail is
|
||||
* marked verified immediately — the admin vouches for the address — and
|
||||
* the personal pond is provisioned exactly like the verify-email path
|
||||
* does, so the account is indistinguishable from a self-registered one.
|
||||
*/
|
||||
async createUser(actor: User, input: AdminCreateUserInput): Promise<AdminUserView> {
|
||||
const user = await this.users.createUser(input);
|
||||
const verified = await this.users.markEmailVerified(user.id);
|
||||
await this.ponds.ensurePersonalPond(verified);
|
||||
await this.audit.record({
|
||||
action: 'user.created_by_admin',
|
||||
actorId: actor.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
});
|
||||
return this.viewOf(verified, await this.pondCountOf(user.id));
|
||||
}
|
||||
|
||||
async list(query: AdminUserListQuery): Promise<AdminUserListView> {
|
||||
const q = query.q?.trim();
|
||||
const where: Prisma.UserWhereInput = q
|
||||
|
||||
@ -29,6 +29,9 @@ export const AUDIT_EVENTS = {
|
||||
'file.integrity_failed': { severity: 'critical' },
|
||||
'grant.created': { severity: 'notice' },
|
||||
'grant.deleted': { severity: 'notice' },
|
||||
'invitation.accepted': { severity: 'notice' },
|
||||
'invitation.created': { severity: 'info' },
|
||||
'invitation.revoked': { severity: 'info' },
|
||||
'job.triggered': { severity: 'info' },
|
||||
'member.added': { severity: 'notice' },
|
||||
'member.removed': { severity: 'notice' },
|
||||
@ -53,6 +56,7 @@ export const AUDIT_EVENTS = {
|
||||
'setup.completed': { severity: 'info' },
|
||||
'setup.preseeded': { severity: 'info' },
|
||||
'setup.smtp_stored': { severity: 'info' },
|
||||
'user.created_by_admin': { severity: 'notice' },
|
||||
'user.deleted': { severity: 'notice' },
|
||||
'user.disabled_set': { severity: 'notice' },
|
||||
'user.pseudonymized': { severity: 'notice' },
|
||||
|
||||
@ -3,6 +3,7 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { GrantsModule } from '../grants/grants.module';
|
||||
import { InvitationsModule } from '../invitations/invitations.module';
|
||||
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { PondsModule } from '../ponds/ponds.module';
|
||||
@ -18,7 +19,7 @@ import { ProxyIdentityService } from './proxy-identity.service';
|
||||
import { SessionsModule } from './sessions.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule],
|
||||
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule],
|
||||
controllers: [AuthController, OidcController],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
@ -9,6 +9,7 @@ import { User } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { InvitationsService } from '../invitations/invitations.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { PondsService } from '../ponds/ponds.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
@ -33,6 +34,7 @@ export class AuthService {
|
||||
private readonly sessions: SessionsService,
|
||||
private readonly mail: MailService,
|
||||
private readonly ponds: PondsService,
|
||||
private readonly invitations: InvitationsService,
|
||||
private readonly rateLimits: RateLimitService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly config: AppConfig,
|
||||
@ -43,10 +45,37 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async signup(input: SignupInput): Promise<void> {
|
||||
if ((await this.settings.get('auth.registrationMode')) === 'closed') {
|
||||
// An invitation token (issue #332) lets exactly one signup through a
|
||||
// closed registration. Claimed atomically BEFORE the account exists;
|
||||
// rolled back if the signup fails (duplicate username), so the invitee
|
||||
// can retry with the same link.
|
||||
const invitation = input.invitationToken
|
||||
? await this.invitations.redeem(input.invitationToken)
|
||||
: null;
|
||||
if (input.invitationToken && !invitation) {
|
||||
throw new BadRequestException({ code: 'token_invalid' });
|
||||
}
|
||||
if (!invitation && (await this.settings.get('auth.registrationMode')) === 'closed') {
|
||||
throw new ForbiddenException({ code: 'registration_closed' });
|
||||
}
|
||||
const user = await this.users.createUser(input);
|
||||
let user: User;
|
||||
try {
|
||||
user = await this.users.createUser(input);
|
||||
} catch (error) {
|
||||
if (invitation) await this.invitations.unredeem(invitation.id);
|
||||
throw error;
|
||||
}
|
||||
if (invitation) {
|
||||
await this.invitations.markAccepted(invitation.id, user.id);
|
||||
await this.audit.record({
|
||||
action: 'invitation.accepted',
|
||||
actorId: user.id,
|
||||
targetType: 'invitation',
|
||||
targetId: invitation.id,
|
||||
});
|
||||
}
|
||||
// The invite link proves nothing about the mailbox (it can be
|
||||
// forwarded), so the usual verification mail still applies.
|
||||
await this.sendVerificationMail(user);
|
||||
await this.audit.record({ action: 'auth.signup', actorId: user.id });
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
@ -19,11 +21,14 @@ import {
|
||||
LOGO_VARIANTS,
|
||||
LogoVariant,
|
||||
MAX_BRANDING_BYTES,
|
||||
PondBranding,
|
||||
} from '@dorfteich/shared';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||
import { RequiresPondRole } from '../permissions/permission.decorators';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { BrandingService } from './branding.service';
|
||||
|
||||
function parseVariant(value: unknown): LogoVariant {
|
||||
@ -52,8 +57,19 @@ export class BrandingController {
|
||||
|
||||
@Public()
|
||||
@Get('logo')
|
||||
async logo(@Query('variant') variant: string | undefined, @Res() res: Response): Promise<void> {
|
||||
const bytes = await this.branding.logoBytes(parseVariant(variant ?? 'light'));
|
||||
async logo(
|
||||
@Query('variant') variant: string | undefined,
|
||||
@Query('pond') pondId: string | undefined,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const wanted = parseVariant(variant ?? 'light');
|
||||
// A pond scope serves the pond's own bytes and nothing else: the caller
|
||||
// already resolved WHICH level applies (`resolveBranding`), so silently
|
||||
// falling back here would mix variants across levels — exactly what #307
|
||||
// forbids.
|
||||
const bytes = pondId
|
||||
? await this.branding.pondLogoBytes(pondId, wanted)
|
||||
: await this.branding.logoBytes(wanted);
|
||||
// No shipped default: without a logo the app renders the instance NAME as
|
||||
// text, so an empty answer here is the honest one.
|
||||
if (!bytes) {
|
||||
@ -69,12 +85,21 @@ export class BrandingController {
|
||||
|
||||
@Public()
|
||||
@Get('favicon')
|
||||
async favicon(@Query('size') size: string | undefined, @Res() res: Response): Promise<void> {
|
||||
async favicon(
|
||||
@Query('size') size: string | undefined,
|
||||
@Query('pond') pondId: string | undefined,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const wanted = Number(size ?? 32);
|
||||
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
|
||||
throw new BadRequestException({ code: 'bad_request' });
|
||||
}
|
||||
const { bytes, uploaded } = await this.branding.faviconBytes(wanted as FaviconSize);
|
||||
const pondBytes = pondId
|
||||
? await this.branding.pondFaviconBytes(pondId, wanted as FaviconSize)
|
||||
: null;
|
||||
const { bytes, uploaded } = pondBytes
|
||||
? { bytes: pondBytes, uploaded: true }
|
||||
: await this.branding.faviconBytes(wanted as FaviconSize);
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
// The `<link rel="icon">` href is a constant in index.html, so this URL
|
||||
// cannot carry a hash — revalidation is the only way a replaced favicon
|
||||
@ -133,3 +158,96 @@ export class BrandingAdminController {
|
||||
return this.branding.clearFavicon(request.user!);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pond-level branding (issue #307). The uploader here is an ordinary Pond
|
||||
* Admin rather than the operator, so the security rules of #306 are not
|
||||
* relaxed by a single line: SVG refused, magic bytes checked server-side,
|
||||
* size caps enforced, content type pinned on serving, no image parsing.
|
||||
*
|
||||
* 404/403 policy: a user who cannot see the pond gets 404 from the pond-role
|
||||
* guard, one who can see but not administer it gets 403.
|
||||
*/
|
||||
@Controller('ponds/:pondId/branding')
|
||||
export class PondBrandingController {
|
||||
constructor(
|
||||
private readonly branding: BrandingService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
/** The pond row the quota is charged to. */
|
||||
private async pondOf(pondId: string): Promise<{ id: string; ownerId: string }> {
|
||||
const pond = await this.prisma.pond.findUnique({
|
||||
where: { id: pondId },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
if (!pond) throw new NotFoundException();
|
||||
return pond;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequiresPondRole('reader', { idParam: 'pondId' })
|
||||
view(@Param('pondId') pondId: string): Promise<PondBranding> {
|
||||
return this.branding.pondBranding(pondId);
|
||||
}
|
||||
|
||||
@Post('logo')
|
||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
||||
async setLogo(
|
||||
@Param('pondId') pondId: string,
|
||||
@Query('variant') variant: string | undefined,
|
||||
@Req() request: AuthedRequest,
|
||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||
): Promise<PondBranding> {
|
||||
const file = files?.find((entry) => entry.fieldname === 'file');
|
||||
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
|
||||
return this.branding.setPondLogo(
|
||||
request.user!,
|
||||
await this.pondOf(pondId),
|
||||
parseVariant(variant ?? 'light'),
|
||||
file.buffer,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('logo')
|
||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||
async clearLogo(
|
||||
@Param('pondId') pondId: string,
|
||||
@Query('variant') variant: string | undefined,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PondBranding> {
|
||||
return this.branding.clearPondLogo(
|
||||
request.user!,
|
||||
await this.pondOf(pondId),
|
||||
parseVariant(variant ?? 'light'),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('favicon')
|
||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
||||
async setFavicon(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||
): Promise<PondBranding> {
|
||||
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
|
||||
const collected = {} as Record<FaviconSize, Buffer>;
|
||||
for (const size of FAVICON_SIZES) {
|
||||
const bytes = byField.get(`png-${size}`);
|
||||
if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
|
||||
collected[size] = bytes;
|
||||
}
|
||||
return this.branding.setPondFavicon(request.user!, await this.pondOf(pondId), collected);
|
||||
}
|
||||
|
||||
@Delete('favicon')
|
||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||
async clearFavicon(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PondBranding> {
|
||||
return this.branding.clearPondFavicon(request.user!, await this.pondOf(pondId));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BrandingAdminController, BrandingController } from './branding.controller';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { QuotasModule } from '../quotas/quotas.module';
|
||||
|
||||
import {
|
||||
BrandingAdminController,
|
||||
BrandingController,
|
||||
PondBrandingController,
|
||||
} from './branding.controller';
|
||||
import { BrandingStorageService } from './branding-storage.service';
|
||||
import { BrandingService } from './branding.service';
|
||||
|
||||
@ -8,7 +15,8 @@ import { BrandingService } from './branding.service';
|
||||
* the pond-level override (#307) can build on the same storage and the same
|
||||
* resolution path instead of a parallel one. */
|
||||
@Module({
|
||||
controllers: [BrandingController, BrandingAdminController],
|
||||
imports: [PermissionsModule, QuotasModule],
|
||||
controllers: [BrandingController, BrandingAdminController, PondBrandingController],
|
||||
providers: [BrandingService, BrandingStorageService],
|
||||
exports: [BrandingService, BrandingStorageService],
|
||||
})
|
||||
|
||||
@ -2,12 +2,16 @@ import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BrandingAsset,
|
||||
BrandingView,
|
||||
FAVICON_SIZES,
|
||||
FaviconSize,
|
||||
LOGO_VARIANTS,
|
||||
LogoVariant,
|
||||
PondBranding,
|
||||
pondSettingsSchema,
|
||||
MAX_BRANDING_BYTES,
|
||||
MAX_LOGO_EDGE,
|
||||
hasPngMagic,
|
||||
@ -17,6 +21,8 @@ import {
|
||||
import { User } from '@prisma/client';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { QuotaService } from '../quotas/quota.service';
|
||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||
import { BrandingStorageService } from './branding-storage.service';
|
||||
|
||||
@ -41,6 +47,8 @@ export class BrandingService {
|
||||
private readonly settings: InstanceSettingsService,
|
||||
private readonly storage: BrandingStorageService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly quotas: QuotaService,
|
||||
) {}
|
||||
|
||||
static logoKey(variant: LogoVariant): string {
|
||||
@ -51,6 +59,26 @@ export class BrandingService {
|
||||
return `instance-favicon-${size}`;
|
||||
}
|
||||
|
||||
/** Pond assets share the directory and the naming rules (issue #307); the
|
||||
* pond id keeps them apart and makes purge a prefix delete. */
|
||||
static pondLogoKey(pondId: string, variant: LogoVariant): string {
|
||||
return `pond-${pondId}-logo-${variant}`;
|
||||
}
|
||||
|
||||
static pondFaviconKey(pondId: string, size: FaviconSize): string {
|
||||
return `pond-${pondId}-favicon-${size}`;
|
||||
}
|
||||
|
||||
/** Every branding file a pond can own — the purge deletes exactly this set
|
||||
* (issue #307). The purge standard is absolute: after it, nothing
|
||||
* referencing the pond survives, rows or files. */
|
||||
static pondKeys(pondId: string): string[] {
|
||||
return [
|
||||
...LOGO_VARIANTS.map((variant) => BrandingService.pondLogoKey(pondId, variant)),
|
||||
...FAVICON_SIZES.map((size) => BrandingService.pondFaviconKey(pondId, size)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects anything that is not a PNG within the caps, before a byte is
|
||||
* written. SVG gets its own message: an operator who tried one deserves to
|
||||
@ -71,11 +99,37 @@ export class BrandingService {
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve the pond's storage for a branding asset, releasing what the asset
|
||||
* it replaces occupied. Doing it in that order means replacing a logo with
|
||||
* one of the same size costs nothing — otherwise every re-upload would eat
|
||||
* the quota again, which is how "a pond admin fills the disk with logos"
|
||||
* happens.
|
||||
*/
|
||||
private async chargeQuota(
|
||||
pond: { id: string; ownerId: string },
|
||||
bytes: number,
|
||||
previous: BrandingAsset | null,
|
||||
): Promise<void> {
|
||||
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
|
||||
try {
|
||||
await this.quotas.checkAndConsume(pond.id, pond.ownerId, bytes);
|
||||
} catch (error) {
|
||||
// Put the released reservation back: a refused upload must not leave
|
||||
// the pond with MORE room than before.
|
||||
if (previous?.byteSize) {
|
||||
await this.quotas.checkAndConsume(pond.id, pond.ownerId, previous.byteSize);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset {
|
||||
return {
|
||||
// Short digest: it only has to change when the bytes change, and it
|
||||
// travels in every logo URL.
|
||||
hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
|
||||
byteSize: bytes.length,
|
||||
...size,
|
||||
};
|
||||
}
|
||||
@ -164,6 +218,150 @@ export class BrandingService {
|
||||
return { bytes, uploaded: false };
|
||||
}
|
||||
|
||||
/** The pond's own branding, defaulted — one place reads the settings blob. */
|
||||
async pondBranding(pondId: string): Promise<PondBranding> {
|
||||
const pond = await this.prisma.pond.findUnique({
|
||||
where: { id: pondId },
|
||||
select: { settings: true },
|
||||
});
|
||||
if (!pond) throw new NotFoundException();
|
||||
return pondSettingsSchema.parse(pond.settings ?? {}).branding;
|
||||
}
|
||||
|
||||
private async writePondBranding(
|
||||
actor: User,
|
||||
pondId: string,
|
||||
next: PondBranding,
|
||||
asset: 'logo' | 'logoDark' | 'favicon',
|
||||
change: 'set' | 'cleared',
|
||||
): Promise<PondBranding> {
|
||||
const pond = await this.prisma.pond.findUniqueOrThrow({
|
||||
where: { id: pondId },
|
||||
select: { settings: true },
|
||||
});
|
||||
const settings = pondSettingsSchema.parse(pond.settings ?? {});
|
||||
await this.prisma.pond.update({
|
||||
where: { id: pondId },
|
||||
data: { settings: { ...settings, branding: next } as object },
|
||||
});
|
||||
await this.audit.record({
|
||||
action: 'branding.changed',
|
||||
actorId: actor.id,
|
||||
targetType: 'pond',
|
||||
targetId: pondId,
|
||||
details: { scope: 'pond', pondId, asset, change },
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pond logo, charged to the pond's storage quota (issue #307).
|
||||
*
|
||||
* Without the charge, branding would be a way around the quota — and
|
||||
* replacing a logo repeatedly would let a pond admin consume disk with no
|
||||
* ceiling. Charged BEFORE the write, like attachments, so a race never
|
||||
* leaves bytes on the volume without a reservation; the bytes a replaced
|
||||
* asset frees are released first, so re-uploading the same logo is free
|
||||
* rather than cumulative.
|
||||
*/
|
||||
async setPondLogo(
|
||||
actor: User,
|
||||
pond: { id: string; ownerId: string },
|
||||
variant: LogoVariant,
|
||||
bytes: Buffer,
|
||||
): Promise<PondBranding> {
|
||||
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
|
||||
const current = await this.pondBranding(pond.id);
|
||||
const previous = variant === 'dark' ? current.logoDark : current.logo;
|
||||
await this.chargeQuota(pond, bytes.length, previous);
|
||||
await this.storage.save(BrandingService.pondLogoKey(pond.id, variant), bytes);
|
||||
const asset = this.assetOf(bytes, size);
|
||||
return this.writePondBranding(
|
||||
actor,
|
||||
pond.id,
|
||||
variant === 'dark' ? { ...current, logoDark: asset } : { ...current, logo: asset },
|
||||
variant === 'dark' ? 'logoDark' : 'logo',
|
||||
'set',
|
||||
);
|
||||
}
|
||||
|
||||
async clearPondLogo(
|
||||
actor: User,
|
||||
pond: { id: string; ownerId: string },
|
||||
variant: LogoVariant,
|
||||
): Promise<PondBranding> {
|
||||
const current = await this.pondBranding(pond.id);
|
||||
const previous = variant === 'dark' ? current.logoDark : current.logo;
|
||||
await this.storage.remove(BrandingService.pondLogoKey(pond.id, variant));
|
||||
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
|
||||
return this.writePondBranding(
|
||||
actor,
|
||||
pond.id,
|
||||
variant === 'dark' ? { ...current, logoDark: null } : { ...current, logo: null },
|
||||
variant === 'dark' ? 'logoDark' : 'logo',
|
||||
'cleared',
|
||||
);
|
||||
}
|
||||
|
||||
async setPondFavicon(
|
||||
actor: User,
|
||||
pond: { id: string; ownerId: string },
|
||||
files: Record<FaviconSize, Buffer>,
|
||||
): Promise<PondBranding> {
|
||||
const checked = Object.entries(files).map(([declared, bytes]) => {
|
||||
const size = this.assertUsablePng(bytes, 512);
|
||||
const expected = Number(declared);
|
||||
if (size.width !== expected || size.height !== expected) {
|
||||
throw new BadRequestException({ code: 'branding_favicon_not_square' });
|
||||
}
|
||||
return { expected: expected as FaviconSize, bytes, size };
|
||||
});
|
||||
const current = await this.pondBranding(pond.id);
|
||||
const total = checked.reduce((sum, entry) => sum + entry.bytes.length, 0);
|
||||
await this.chargeQuota(pond, total, current.favicon);
|
||||
for (const entry of checked) {
|
||||
await this.storage.save(BrandingService.pondFaviconKey(pond.id, entry.expected), entry.bytes);
|
||||
}
|
||||
const small = checked.find((entry) => entry.expected === 32)!;
|
||||
// The pair is charged together, so the stored size is the pair's — that
|
||||
// is what a later release has to give back.
|
||||
const asset = { ...this.assetOf(small.bytes, small.size), byteSize: total };
|
||||
return this.writePondBranding(actor, pond.id, { ...current, favicon: asset }, 'favicon', 'set');
|
||||
}
|
||||
|
||||
async clearPondFavicon(
|
||||
actor: User,
|
||||
pond: { id: string; ownerId: string },
|
||||
): Promise<PondBranding> {
|
||||
const current = await this.pondBranding(pond.id);
|
||||
for (const size of FAVICON_SIZES) {
|
||||
await this.storage.remove(BrandingService.pondFaviconKey(pond.id, size));
|
||||
}
|
||||
if (current.favicon?.byteSize) await this.quotas.release(pond.id, current.favicon.byteSize);
|
||||
return this.writePondBranding(
|
||||
actor,
|
||||
pond.id,
|
||||
{ ...current, favicon: null },
|
||||
'favicon',
|
||||
'cleared',
|
||||
);
|
||||
}
|
||||
|
||||
/** Bytes for a pond asset — null when the pond has none at that slot, which
|
||||
* is what makes the caller fall back to the instance level. */
|
||||
pondLogoBytes(pondId: string, variant: LogoVariant): Promise<Buffer | null> {
|
||||
return this.storage.read(BrandingService.pondLogoKey(pondId, variant));
|
||||
}
|
||||
|
||||
pondFaviconBytes(pondId: string, size: FaviconSize): Promise<Buffer | null> {
|
||||
return this.storage.read(BrandingService.pondFaviconKey(pondId, size));
|
||||
}
|
||||
|
||||
/** Removes every branding file of a pond (issue #307's purge obligation). */
|
||||
async removePondAssets(pondId: string): Promise<void> {
|
||||
for (const key of BrandingService.pondKeys(pondId)) await this.storage.remove(key);
|
||||
}
|
||||
|
||||
private record(
|
||||
admin: User,
|
||||
asset: 'logo' | 'logoDark' | 'favicon',
|
||||
|
||||
256
apps/api/src/branding/pond-branding.e2e.db.test.ts
Normal file
256
apps/api/src/branding/pond-branding.e2e.db.test.ts
Normal file
@ -0,0 +1,256 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||
import {
|
||||
createTestPrisma,
|
||||
deletePondsWhere,
|
||||
grantOwnerAdmin,
|
||||
hasTestDb,
|
||||
uniqueSuffix,
|
||||
} from '../testing/test-db';
|
||||
import { TrashService } from '../trash/trash.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
import { BrandingService } from './branding.service';
|
||||
import { BrandingStorageService } from './branding-storage.service';
|
||||
|
||||
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
function crc32(buf: Buffer): number {
|
||||
let c = 0xffffffff;
|
||||
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function chunk(type: string, data: Buffer): Buffer {
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length);
|
||||
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body));
|
||||
return Buffer.concat([length, body, crc]);
|
||||
}
|
||||
/** A real PNG — the api reads the IHDR, so the header has to be genuine. */
|
||||
function png(size: number): Buffer {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(size, 0);
|
||||
ihdr.writeUInt32BE(size, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', deflateSync(Buffer.alloc(size * (size * 4 + 1)))),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasTestDb)('pond branding (e2e, issue #307)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaClient;
|
||||
let storage: BrandingStorageService;
|
||||
let brandingDir: string;
|
||||
const suffix = uniqueSuffix();
|
||||
const password = 'teichmarke mit eigenem logo 1';
|
||||
const owner = { username: `pb-${suffix}` };
|
||||
const member = { username: `pbm-${suffix}` };
|
||||
let ownerCookie: string;
|
||||
let memberCookie: string;
|
||||
let pondId: string;
|
||||
|
||||
const api = () => request(app.getHttpServer());
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = createTestPrisma();
|
||||
await prisma.rateLimit.deleteMany({});
|
||||
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-pondbranding-'));
|
||||
process.env.BRANDING_DIR = brandingDir;
|
||||
app = await createTestApp();
|
||||
storage = app.get(BrandingStorageService);
|
||||
const users = app.get(UsersService);
|
||||
const tokens = app.get(AuthTokensService);
|
||||
// Verification through the endpoint, not `markEmailVerified`: only this
|
||||
// path creates the personal pond these tests brand.
|
||||
const verify = async (userId: string): Promise<void> => {
|
||||
await api()
|
||||
.post('/api/v1/auth/verify-email')
|
||||
.send({ token: await tokens.issue(userId, 'EMAIL_VERIFICATION', 600) })
|
||||
.expect(204);
|
||||
};
|
||||
|
||||
const ownerUser = await users.createUser({
|
||||
username: owner.username,
|
||||
email: `${owner.username}@example.org`,
|
||||
displayName: `Pond Branding Owner ${suffix}`,
|
||||
password,
|
||||
locale: 'en',
|
||||
});
|
||||
await verify(ownerUser.id);
|
||||
const memberUser = await users.createUser({
|
||||
username: member.username,
|
||||
email: `${member.username}@example.org`,
|
||||
displayName: `Pond Branding Member ${suffix}`,
|
||||
password,
|
||||
locale: 'en',
|
||||
});
|
||||
await verify(memberUser.id);
|
||||
|
||||
const login = async (username: string): Promise<string> =>
|
||||
sessionCookieOf(
|
||||
await api()
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ usernameOrEmail: username, password })
|
||||
.expect(200),
|
||||
);
|
||||
ownerCookie = await login(owner.username);
|
||||
memberCookie = await login(member.username);
|
||||
|
||||
pondId = (
|
||||
await prisma.pond.findFirstOrThrow({ where: { ownerId: ownerUser.id, type: 'PERSONAL' } })
|
||||
).id;
|
||||
// A reader on the same pond: may see it, may not administer it. Through
|
||||
// the API, not a raw row — the permission cache would not see the row
|
||||
// (the documented rule for grants in tests).
|
||||
await api()
|
||||
.post(`/api/v1/ponds/${pondId}/grants`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.send({
|
||||
subjectType: 'user',
|
||||
subjectId: memberUser.id,
|
||||
role: 'reader',
|
||||
scopeType: 'pond',
|
||||
effect: 'allow',
|
||||
})
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.roleGrant.deleteMany({
|
||||
where: { pond: { owner: { username: { contains: suffix } } } },
|
||||
});
|
||||
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||
await prisma.$disconnect();
|
||||
await app.close();
|
||||
await rm(brandingDir, { recursive: true, force: true });
|
||||
delete process.env.BRANDING_DIR;
|
||||
});
|
||||
|
||||
it('stores a pond logo, reports it, and serves it under the pond scope', async () => {
|
||||
const view = await api()
|
||||
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.attach('file', png(64), 'logo.png')
|
||||
.expect(201);
|
||||
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
|
||||
|
||||
const served = await api()
|
||||
.get(`/api/v1/branding/logo?variant=light&pond=${pondId}`)
|
||||
.expect(200);
|
||||
expect(served.headers['content-type']).toContain('image/png');
|
||||
|
||||
// Without the pond scope the instance level answers — 404 here, since no
|
||||
// instance logo is set. The two levels never leak into each other.
|
||||
await api().get('/api/v1/branding/logo?variant=light').expect(404);
|
||||
});
|
||||
|
||||
it('charges the pond quota and gives the bytes back when the logo is replaced', async () => {
|
||||
const usageOf = async (): Promise<number> =>
|
||||
Number(
|
||||
(
|
||||
await prisma.pondUsage.findUnique({
|
||||
where: { pondId },
|
||||
select: { storageBytesUsed: true },
|
||||
})
|
||||
)?.storageBytesUsed ?? 0,
|
||||
);
|
||||
const before = await usageOf();
|
||||
|
||||
const big = png(120);
|
||||
await api()
|
||||
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.attach('file', big, 'logo.png')
|
||||
.expect(201);
|
||||
const afterUpload = await usageOf();
|
||||
expect(afterUpload).toBe(before + big.length);
|
||||
|
||||
// Replacing releases the old reservation first — otherwise re-uploading
|
||||
// the same logo would eat the quota again and again.
|
||||
await api()
|
||||
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.attach('file', big, 'logo.png')
|
||||
.expect(201);
|
||||
expect(await usageOf()).toBe(afterUpload);
|
||||
|
||||
await api()
|
||||
.delete(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.expect(200);
|
||||
expect(await usageOf()).toBe(before);
|
||||
});
|
||||
|
||||
it('refuses SVG at the pond level too — the rules do not relax for a pond admin', async () => {
|
||||
const res = await api()
|
||||
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.attach('file', Buffer.from('<svg xmlns="x"><script/></svg>'), 'x.png')
|
||||
.expect(400);
|
||||
expect(res.body.code).toBe('branding_svg_rejected');
|
||||
});
|
||||
|
||||
it('lets a member read the pond branding but not change it', async () => {
|
||||
await api().get(`/api/v1/ponds/${pondId}/branding`).set('Cookie', memberCookie).expect(200);
|
||||
await api()
|
||||
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
||||
.set('Cookie', memberCookie)
|
||||
.attach('file', png(32), 'x.png')
|
||||
.expect(403);
|
||||
await api()
|
||||
.delete(`/api/v1/ponds/${pondId}/branding/favicon`)
|
||||
.set('Cookie', memberCookie)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('purging the pond removes its branding files', async () => {
|
||||
// A pond of its own, so the purge does not take the shared fixture with it.
|
||||
const ownerRow = await prisma.user.findFirstOrThrow({ where: { username: owner.username } });
|
||||
const created = await prisma.pond.create({
|
||||
data: {
|
||||
name: `Purge Branding ${suffix}`,
|
||||
slug: `purge-branding-${suffix}`,
|
||||
type: 'SHARED',
|
||||
ownerId: ownerRow.id,
|
||||
},
|
||||
});
|
||||
// Raw grant row, before this pond's first permission query — the
|
||||
// documented exception to "grants through the API".
|
||||
await grantOwnerAdmin(prisma, created.id, ownerRow.id);
|
||||
await api()
|
||||
.post(`/api/v1/ponds/${created.id}/branding/logo?variant=light`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.attach('file', png(48), 'logo.png')
|
||||
.expect(201);
|
||||
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).not.toBeNull();
|
||||
|
||||
await prisma.pond.update({ where: { id: created.id }, data: { deletedAt: new Date() } });
|
||||
const trash = app.get(TrashService);
|
||||
await trash.purgePondNow(ownerRow, created.id);
|
||||
|
||||
// The purge standard is absolute: after it nothing referencing the pond
|
||||
// survives — rows OR files.
|
||||
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).toBeNull();
|
||||
});
|
||||
});
|
||||
50
apps/api/src/invitations/invitations.controller.ts
Normal file
50
apps/api/src/invitations/invitations.controller.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common';
|
||||
import {
|
||||
CreateInvitationInput,
|
||||
InvitationListView,
|
||||
InvitationPreview,
|
||||
InvitationView,
|
||||
createInvitationSchema,
|
||||
invitationPreviewSchema,
|
||||
} from '@dorfteich/shared';
|
||||
|
||||
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||
import { InvitationsService } from './invitations.service';
|
||||
|
||||
/** Peer invitations (issue #332). */
|
||||
@AuthenticatedOnly()
|
||||
@Controller('invitations')
|
||||
export class InvitationsController {
|
||||
constructor(private readonly invitations: InvitationsService) {}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(createInvitationSchema)) input: CreateInvitationInput,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<InvitationView> {
|
||||
return this.invitations.create(request.user!, input.email);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(@Req() request: AuthedRequest): Promise<InvitationListView> {
|
||||
return this.invitations.list(request.user!);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
async revoke(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||
await this.invitations.revoke(request.user!, id);
|
||||
}
|
||||
|
||||
/** The signup screen's link check — POST keeps the token out of logs. */
|
||||
@Public()
|
||||
@Post('preview')
|
||||
@HttpCode(200)
|
||||
async preview(
|
||||
@Body(new ZodValidationPipe(invitationPreviewSchema)) input: { token: string },
|
||||
): Promise<InvitationPreview> {
|
||||
return this.invitations.preview(input.token);
|
||||
}
|
||||
}
|
||||
246
apps/api/src/invitations/invitations.e2e.db.test.ts
Normal file
246
apps/api/src/invitations/invitations.e2e.db.test.ts
Normal file
@ -0,0 +1,246 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { InvitationListView, InvitationPreview, InvitationView } from '@dorfteich/shared';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
/**
|
||||
* Peer invitations end to end (issue #332): inviting mails a single-use
|
||||
* link, open invitations are quota-bound per user, and a valid token lets
|
||||
* exactly one signup through a closed registration. Settings written here
|
||||
* are restored inside each test and the keys are deleted in afterAll
|
||||
* (shared-DB rule).
|
||||
*/
|
||||
describe.skipIf(!hasTestDb)('invitations (e2e, issue #332)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaClient;
|
||||
const suffix = uniqueSuffix();
|
||||
const password = 'einladungen sind praktisch 1';
|
||||
const ids: Record<string, string> = {};
|
||||
const cookies: Record<string, string> = {};
|
||||
|
||||
const api = () => request(app.getHttpServer());
|
||||
const settings = () => app.get(InstanceSettingsService);
|
||||
|
||||
async function makeUser(handle: string): Promise<void> {
|
||||
const users = app.get(UsersService);
|
||||
const username = `inv-${handle}-${suffix}`;
|
||||
const user = await users.createUser({
|
||||
username,
|
||||
email: `${username}@example.org`,
|
||||
displayName: `Inv ${handle}`,
|
||||
password,
|
||||
locale: 'en',
|
||||
});
|
||||
await users.markEmailVerified(user.id);
|
||||
ids[handle] = user.id;
|
||||
cookies[handle] = sessionCookieOf(
|
||||
await api()
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ usernameOrEmail: username, password })
|
||||
.expect(200),
|
||||
);
|
||||
}
|
||||
|
||||
/** The raw token only travels in the mail — fish it out of the outbox. */
|
||||
async function mailedTokenFor(email: string): Promise<string> {
|
||||
const mail = await prisma.mailOutbox.findFirstOrThrow({
|
||||
where: { toAddress: email },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const match = /invitation=([A-Za-z0-9_-]+)/.exec(mail.textBody);
|
||||
expect(match).not.toBeNull();
|
||||
return match![1]!;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = createTestPrisma();
|
||||
await prisma.rateLimit.deleteMany({});
|
||||
app = await createTestApp();
|
||||
await makeUser('alice');
|
||||
await makeUser('quota');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.instanceSetting.deleteMany({
|
||||
where: { key: { in: ['auth.registrationMode', 'invitations.maxOpenPerUser'] } },
|
||||
});
|
||||
const all = Object.values(ids);
|
||||
await prisma.invitation.deleteMany({ where: { inviterId: { in: all } } });
|
||||
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
||||
await prisma.session.deleteMany({ where: { userId: { in: all } } });
|
||||
await deletePondsWhere(prisma, { ownerId: { in: all } });
|
||||
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
||||
await prisma.$disconnect();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('invites, lists, and mails a single-use signup link', async () => {
|
||||
const invitee = `guest-${suffix}@example.org`;
|
||||
const res = await api()
|
||||
.post('/api/v1/invitations')
|
||||
.set('Cookie', cookies.alice!)
|
||||
.send({ email: invitee })
|
||||
.expect(201);
|
||||
const view = res.body as InvitationView;
|
||||
expect(view.status).toBe('pending');
|
||||
|
||||
const list = (await api().get('/api/v1/invitations').set('Cookie', cookies.alice!).expect(200))
|
||||
.body as InvitationListView;
|
||||
expect(list.open).toBe(1);
|
||||
expect(list.maxOpen).toBe(5);
|
||||
expect(list.invitations.map((i) => i.id)).toContain(view.id);
|
||||
|
||||
// The mail exists and the public preview identifies the inviter.
|
||||
const token = await mailedTokenFor(invitee);
|
||||
const preview = (await api().post('/api/v1/invitations/preview').send({ token }).expect(200))
|
||||
.body as InvitationPreview;
|
||||
expect(preview.email).toBe(invitee);
|
||||
expect(preview.inviterName).toBe('Inv alice');
|
||||
});
|
||||
|
||||
it('a valid token passes a closed registration exactly once; a burned signup attempt does not consume it', async () => {
|
||||
const invitee = `joiner-${suffix}@example.org`;
|
||||
await api()
|
||||
.post('/api/v1/invitations')
|
||||
.set('Cookie', cookies.alice!)
|
||||
.send({ email: invitee })
|
||||
.expect(201);
|
||||
const token = await mailedTokenFor(invitee);
|
||||
|
||||
await settings().set('auth.registrationMode', 'closed', ids.alice!);
|
||||
try {
|
||||
// Closed without a token: refused.
|
||||
await api()
|
||||
.post('/api/v1/auth/signup')
|
||||
.send({
|
||||
username: `inv-blocked-${suffix}`,
|
||||
email: `inv-blocked-${suffix}@example.org`,
|
||||
displayName: 'Blocked',
|
||||
password,
|
||||
locale: 'en',
|
||||
})
|
||||
.expect(403);
|
||||
|
||||
// A failing signup (taken username) must NOT burn the token.
|
||||
await api()
|
||||
.post('/api/v1/auth/signup')
|
||||
.send({
|
||||
username: `inv-alice-${suffix}`, // taken
|
||||
email: invitee,
|
||||
displayName: 'Joiner',
|
||||
password,
|
||||
locale: 'en',
|
||||
invitationToken: token,
|
||||
})
|
||||
.expect(409);
|
||||
|
||||
// Same link, fresh username: through, despite closed mode.
|
||||
const username = `inv-joiner-${suffix}`;
|
||||
await api()
|
||||
.post('/api/v1/auth/signup')
|
||||
.send({
|
||||
username,
|
||||
email: invitee,
|
||||
displayName: 'Joiner',
|
||||
password,
|
||||
locale: 'en',
|
||||
invitationToken: token,
|
||||
})
|
||||
.expect(201);
|
||||
const joiner = await prisma.user.findUniqueOrThrow({ where: { username } });
|
||||
ids.joiner = joiner.id;
|
||||
|
||||
// The invitation is tied to the new account…
|
||||
const accepted = await prisma.invitation.findFirstOrThrow({
|
||||
where: { acceptedUserId: joiner.id },
|
||||
});
|
||||
expect(accepted.acceptedAt).not.toBeNull();
|
||||
|
||||
// …and the token is single-use.
|
||||
await api()
|
||||
.post('/api/v1/auth/signup')
|
||||
.send({
|
||||
username: `inv-replay-${suffix}`,
|
||||
email: `inv-replay-${suffix}@example.org`,
|
||||
displayName: 'Replay',
|
||||
password,
|
||||
locale: 'en',
|
||||
invitationToken: token,
|
||||
})
|
||||
.expect(400);
|
||||
} finally {
|
||||
await settings().set('auth.registrationMode', 'open', ids.alice!);
|
||||
}
|
||||
});
|
||||
|
||||
it('enforces the open-invitations quota and frees it on revoke', async () => {
|
||||
await settings().set('invitations.maxOpenPerUser', 2, ids.alice!);
|
||||
try {
|
||||
const first = (
|
||||
await api()
|
||||
.post('/api/v1/invitations')
|
||||
.set('Cookie', cookies.quota!)
|
||||
.send({ email: `q1-${suffix}@example.org` })
|
||||
.expect(201)
|
||||
).body as InvitationView;
|
||||
await api()
|
||||
.post('/api/v1/invitations')
|
||||
.set('Cookie', cookies.quota!)
|
||||
.send({ email: `q2-${suffix}@example.org` })
|
||||
.expect(201);
|
||||
await api()
|
||||
.post('/api/v1/invitations')
|
||||
.set('Cookie', cookies.quota!)
|
||||
.send({ email: `q3-${suffix}@example.org` })
|
||||
.expect(400)
|
||||
.expect((r) => expect((r.body as { code: string }).code).toBe('invitation_quota_reached'));
|
||||
|
||||
// Revoking an open invitation frees the slot…
|
||||
await api()
|
||||
.delete(`/api/v1/invitations/${first.id}`)
|
||||
.set('Cookie', cookies.quota!)
|
||||
.expect(204);
|
||||
await api()
|
||||
.post('/api/v1/invitations')
|
||||
.set('Cookie', cookies.quota!)
|
||||
.send({ email: `q3-${suffix}@example.org` })
|
||||
.expect(201);
|
||||
|
||||
// …and the revoked token is dead.
|
||||
const revokedToken = await mailedTokenFor(`q1-${suffix}@example.org`);
|
||||
await api().post('/api/v1/invitations/preview').send({ token: revokedToken }).expect(400);
|
||||
} finally {
|
||||
await settings().set('invitations.maxOpenPerUser', 5, ids.alice!);
|
||||
}
|
||||
});
|
||||
|
||||
it('quota 0 disables inviting entirely', async () => {
|
||||
await settings().set('invitations.maxOpenPerUser', 0, ids.alice!);
|
||||
try {
|
||||
await api()
|
||||
.post('/api/v1/invitations')
|
||||
.set('Cookie', cookies.alice!)
|
||||
.send({ email: `off-${suffix}@example.org` })
|
||||
.expect(403)
|
||||
.expect((r) => expect((r.body as { code: string }).code).toBe('invitations_disabled'));
|
||||
} finally {
|
||||
await settings().set('invitations.maxOpenPerUser', 5, ids.alice!);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a session for create/list/revoke but not for preview', async () => {
|
||||
await api().post('/api/v1/invitations').send({ email: 'nope@example.org' }).expect(401);
|
||||
await api().get('/api/v1/invitations').expect(401);
|
||||
await api()
|
||||
.post('/api/v1/invitations/preview')
|
||||
.send({ token: 'x'.repeat(32) })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
13
apps/api/src/invitations/invitations.module.ts
Normal file
13
apps/api/src/invitations/invitations.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { InvitationsController } from './invitations.controller';
|
||||
import { InvitationsService } from './invitations.service';
|
||||
|
||||
@Module({
|
||||
imports: [MailModule],
|
||||
controllers: [InvitationsController],
|
||||
providers: [InvitationsService],
|
||||
exports: [InvitationsService],
|
||||
})
|
||||
export class InvitationsModule {}
|
||||
199
apps/api/src/invitations/invitations.service.ts
Normal file
199
apps/api/src/invitations/invitations.service.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
InvitationListView,
|
||||
InvitationPreview,
|
||||
InvitationStatus,
|
||||
InvitationView,
|
||||
} from '@dorfteich/shared';
|
||||
import { Invitation, User } from '@prisma/client';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RateLimitService } from '../rate-limit/rate-limit.service';
|
||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||
|
||||
export const INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||
|
||||
// Anti-spam backstop besides the open-invitations quota: without it a
|
||||
// revoke-and-recreate loop would allow unlimited mail volume while never
|
||||
// exceeding the quota.
|
||||
const CREATE_LIMIT = { limit: 20, windowSeconds: 24 * 60 * 60 };
|
||||
|
||||
/**
|
||||
* Peer invitations (issue #332). A user invites an e-mail address; the
|
||||
* mailed single-use token lets exactly one signup through even while
|
||||
* registration is closed (auth.service). Open (pending, unexpired)
|
||||
* invitations count against the per-user quota
|
||||
* `invitations.maxOpenPerUser` — 0 turns the feature off. Only the
|
||||
* SHA-256 hash of the token is stored (auth-tokens pattern); revoked and
|
||||
* accepted rows are kept so the settings UI can show history.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InvitationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mail: MailService,
|
||||
private readonly rateLimits: RateLimitService,
|
||||
private readonly settings: InstanceSettingsService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
async create(user: User, email: string): Promise<InvitationView> {
|
||||
const maxOpen = (await this.settings.get('invitations.maxOpenPerUser')) as number;
|
||||
if (maxOpen === 0) throw new ForbiddenException({ code: 'invitations_disabled' });
|
||||
if ((await this.openCount(user.id)) >= maxOpen) {
|
||||
throw new BadRequestException({ code: 'invitation_quota_reached' });
|
||||
}
|
||||
const limit = await this.rateLimits.hit(
|
||||
'invitation-create',
|
||||
user.id,
|
||||
CREATE_LIMIT.limit,
|
||||
CREATE_LIMIT.windowSeconds,
|
||||
);
|
||||
if (!limit.allowed) {
|
||||
throw new HttpException({ code: 'rate_limited' }, HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
const raw = randomBytes(32).toString('base64url');
|
||||
const row = await this.prisma.invitation.create({
|
||||
data: {
|
||||
inviterId: user.id,
|
||||
email: email.toLowerCase(),
|
||||
tokenHash: hashToken(raw),
|
||||
expiresAt: new Date(Date.now() + INVITATION_TTL_SECONDS * 1000),
|
||||
},
|
||||
});
|
||||
// The invitee has no account and no locale yet — the instance default
|
||||
// decides the mail language. The greeting falls back to the address.
|
||||
await this.mail.enqueue(
|
||||
row.email,
|
||||
'invitation',
|
||||
{
|
||||
displayName: row.email,
|
||||
inviterName: user.displayName,
|
||||
link: `${this.config.env.APP_BASE_URL}/signup?invitation=${raw}`,
|
||||
},
|
||||
(await this.settings.get('instance.defaultLocale')) as 'de' | 'en',
|
||||
);
|
||||
await this.audit.record({
|
||||
action: 'invitation.created',
|
||||
actorId: user.id,
|
||||
targetType: 'invitation',
|
||||
targetId: row.id,
|
||||
});
|
||||
return this.viewOf(row);
|
||||
}
|
||||
|
||||
async list(user: User): Promise<InvitationListView> {
|
||||
const rows = await this.prisma.invitation.findMany({
|
||||
where: { inviterId: user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
return {
|
||||
invitations: rows.map((row) => this.viewOf(row)),
|
||||
open: await this.openCount(user.id),
|
||||
maxOpen: (await this.settings.get('invitations.maxOpenPerUser')) as number,
|
||||
};
|
||||
}
|
||||
|
||||
async revoke(user: User, id: string): Promise<void> {
|
||||
const row = await this.prisma.invitation.findFirst({
|
||||
where: { id, inviterId: user.id },
|
||||
});
|
||||
if (!row) throw new NotFoundException();
|
||||
if (row.acceptedAt) throw new BadRequestException({ code: 'invitation_already_accepted' });
|
||||
if (row.revokedAt) return; // idempotent
|
||||
await this.prisma.invitation.update({ where: { id }, data: { revokedAt: new Date() } });
|
||||
await this.audit.record({
|
||||
action: 'invitation.revoked',
|
||||
actorId: user.id,
|
||||
targetType: 'invitation',
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
|
||||
/** What the signup screen may show for a link before it is used. */
|
||||
async preview(raw: string): Promise<InvitationPreview> {
|
||||
const row = await this.prisma.invitation.findUnique({
|
||||
where: { tokenHash: hashToken(raw) },
|
||||
include: { inviter: true },
|
||||
});
|
||||
if (!row || row.revokedAt || row.acceptedAt || row.expiresAt <= new Date()) {
|
||||
throw new BadRequestException({ code: 'token_invalid' });
|
||||
}
|
||||
return { email: row.email, inviterName: row.inviter.displayName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claims the token (only one signup can flip acceptedAt from
|
||||
* null). Returns the row, or null for unknown/revoked/expired/used
|
||||
* tokens. The caller un-redeems if the signup fails afterwards.
|
||||
*/
|
||||
async redeem(raw: string): Promise<Invitation | null> {
|
||||
const result = await this.prisma.invitation.updateMany({
|
||||
where: {
|
||||
tokenHash: hashToken(raw),
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
data: { acceptedAt: new Date() },
|
||||
});
|
||||
if (result.count === 0) return null;
|
||||
return this.prisma.invitation.findUnique({ where: { tokenHash: hashToken(raw) } });
|
||||
}
|
||||
|
||||
/** Ties the redeemed invitation to the account it created. */
|
||||
async markAccepted(id: string, userId: string): Promise<void> {
|
||||
await this.prisma.invitation.update({ where: { id }, data: { acceptedUserId: userId } });
|
||||
}
|
||||
|
||||
/** Rolls a redeem back when the signup it gated failed (e.g. duplicate
|
||||
* username) — the invitee must be able to try again with the same link. */
|
||||
async unredeem(id: string): Promise<void> {
|
||||
await this.prisma.invitation.updateMany({
|
||||
where: { id, acceptedUserId: null },
|
||||
data: { acceptedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
private openCount(inviterId: string): Promise<number> {
|
||||
return this.prisma.invitation.count({
|
||||
where: { inviterId, revokedAt: null, acceptedAt: null, expiresAt: { gt: new Date() } },
|
||||
});
|
||||
}
|
||||
|
||||
private viewOf(row: Invitation): InvitationView {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
status: statusOf(row),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function statusOf(row: Invitation): InvitationStatus {
|
||||
if (row.revokedAt) return 'revoked';
|
||||
if (row.acceptedAt) return 'accepted';
|
||||
if (row.expiresAt <= new Date()) return 'expired';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function hashToken(raw: string): string {
|
||||
return createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { apiI18n } from '../i18n/api-i18n';
|
||||
|
||||
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest';
|
||||
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest' | 'invitation';
|
||||
|
||||
export interface RenderedMail {
|
||||
subject: string;
|
||||
@ -15,14 +15,15 @@ export interface RenderedMail {
|
||||
*/
|
||||
export function renderMail(
|
||||
template: MailTemplate,
|
||||
params: { displayName: string; link: string },
|
||||
// Extra keys (e.g. inviterName, #332) interpolate into the body text.
|
||||
params: { displayName: string; link: string } & Record<string, string>,
|
||||
locale: 'de' | 'en',
|
||||
): RenderedMail {
|
||||
const t = (key: string, options: Record<string, string> = {}): string =>
|
||||
apiI18n.t(`mails:${key}`, { lng: locale, ...options });
|
||||
|
||||
const greeting = t('common.greeting', { displayName: params.displayName });
|
||||
const body = t(`${template}.body`);
|
||||
const body = t(`${template}.body`, params);
|
||||
const action = t(`${template}.action`);
|
||||
const expiry = t(`${template}.expiry`);
|
||||
const ignore = t('common.ignoreHint');
|
||||
|
||||
@ -14,7 +14,7 @@ export class MailService {
|
||||
async enqueue(
|
||||
to: string,
|
||||
template: MailTemplate,
|
||||
params: { displayName: string; link: string },
|
||||
params: { displayName: string; link: string } & Record<string, string>,
|
||||
locale: 'de' | 'en',
|
||||
): Promise<void> {
|
||||
const rendered = renderMail(template, params, locale);
|
||||
|
||||
@ -26,48 +26,56 @@ describe('sort-key helpers (issue #45)', () => {
|
||||
* pattern — repeatedly drop the last page between the first two — must never
|
||||
* collide and never overflow the key length, because the caller rebalances
|
||||
* when {@link nextKeyOrRebalance} returns null.
|
||||
*
|
||||
* Under parallel CI load the 10.000 iterations have repeatedly exceeded the
|
||||
* default 5 s per-test timeout (runs 685, 699 — same code passed on rerun),
|
||||
* so this test carries its own budget.
|
||||
*/
|
||||
it('10.000 adversarial reorders never collide or overflow (rebalance verified)', () => {
|
||||
// Start with five pages in a fixed order.
|
||||
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
|
||||
let rebalances = 0;
|
||||
it(
|
||||
'10.000 adversarial reorders never collide or overflow (rebalance verified)',
|
||||
{ timeout: 30_000 },
|
||||
() => {
|
||||
// Start with five pages in a fixed order.
|
||||
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
|
||||
let rebalances = 0;
|
||||
|
||||
const rebalance = (): void => {
|
||||
const keys = evenlySpacedKeys(order.length);
|
||||
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
|
||||
rebalances += 1;
|
||||
};
|
||||
const rebalance = (): void => {
|
||||
const keys = evenlySpacedKeys(order.length);
|
||||
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
|
||||
rebalances += 1;
|
||||
};
|
||||
|
||||
for (let i = 0; i < 10_000; i += 1) {
|
||||
// Move the last page to sit between the first and second — the tightest
|
||||
// possible gap, which is what grows key length fastest.
|
||||
const moved = order[order.length - 1]!;
|
||||
const rest = order.slice(0, -1);
|
||||
const afterKey = rest[0]!.key;
|
||||
const beforeKey = rest[1]!.key;
|
||||
for (let i = 0; i < 10_000; i += 1) {
|
||||
// Move the last page to sit between the first and second — the tightest
|
||||
// possible gap, which is what grows key length fastest.
|
||||
const moved = order[order.length - 1]!;
|
||||
const rest = order.slice(0, -1);
|
||||
const afterKey = rest[0]!.key;
|
||||
const beforeKey = rest[1]!.key;
|
||||
|
||||
const key = nextKeyOrRebalance(afterKey, beforeKey);
|
||||
if (key === null) {
|
||||
// Rebalance keeps the CURRENT order, then retry the move once.
|
||||
rebalance();
|
||||
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
|
||||
expect(k2).not.toBeNull();
|
||||
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
|
||||
} else {
|
||||
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
|
||||
const key = nextKeyOrRebalance(afterKey, beforeKey);
|
||||
if (key === null) {
|
||||
// Rebalance keeps the CURRENT order, then retry the move once.
|
||||
rebalance();
|
||||
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
|
||||
expect(k2).not.toBeNull();
|
||||
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
|
||||
} else {
|
||||
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
|
||||
}
|
||||
|
||||
// Invariants after every move: keys unique, bounded, and consistent with
|
||||
// the intended array order.
|
||||
const keys = order.map((p) => p.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
|
||||
for (let j = 1; j < keys.length; j += 1) {
|
||||
expect(keys[j - 1]! < keys[j]!).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Invariants after every move: keys unique, bounded, and consistent with
|
||||
// the intended array order.
|
||||
const keys = order.map((p) => p.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
|
||||
for (let j = 1; j < keys.length; j += 1) {
|
||||
expect(keys[j - 1]! < keys[j]!).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// The adversarial pattern must have forced at least one rebalance.
|
||||
expect(rebalances).toBeGreaterThan(0);
|
||||
});
|
||||
// The adversarial pattern must have forced at least one rebalance.
|
||||
expect(rebalances).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -21,6 +21,9 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
*/
|
||||
export const INSTANCE_SETTINGS = {
|
||||
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
|
||||
// Peer invitations (issue #332): max OPEN (pending, unexpired)
|
||||
// invitations per user; 0 turns inviting off entirely.
|
||||
'invitations.maxOpenPerUser': z.number().int().min(0).default(5),
|
||||
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
|
||||
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
|
||||
// Branding assets (issue #306). Metadata only — the PNG bytes live under
|
||||
|
||||
@ -317,6 +317,42 @@ describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)'
|
||||
expect(locked.body.code).toBe('setup_locked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('env pre-seeding with invalid values (issue #325)', () => {
|
||||
const dbName = `dorfteich_preseed_bad_${suffix}`;
|
||||
let app: INestApplication;
|
||||
const badEnv = {
|
||||
SETUP_ADMIN_USERNAME: `preseed-bad-${suffix}`,
|
||||
SETUP_ADMIN_EMAIL: `preseed-bad-${suffix}@example.org`,
|
||||
SETUP_ADMIN_PASSWORD: 'short',
|
||||
} as const;
|
||||
|
||||
beforeAll(async () => {
|
||||
const url = await createFreshDatabase(dbName);
|
||||
process.env.TEST_DATABASE_URL = url;
|
||||
process.env.SECRETS_FILE = join(
|
||||
mkdtempSync(join(tmpdir(), 'dorfteich-preseed-bad-')),
|
||||
'secrets.env',
|
||||
);
|
||||
Object.assign(process.env, badEnv);
|
||||
app = await createTestApp();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
for (const key of Object.keys(badEnv)) delete process.env[key];
|
||||
await app.close();
|
||||
await dropDatabase(dbName);
|
||||
});
|
||||
|
||||
it('fails the boot naming the SETUP_* variable, not a raw ZodError', async () => {
|
||||
await expect(app.get(SetupService).preseedFromEnv()).rejects.toThrow(
|
||||
/SETUP_ADMIN_PASSWORD must be at least 10 characters/,
|
||||
);
|
||||
// Fail-fast left nothing half-seeded: the wizard is still pending.
|
||||
const status = await request(app.getHttpServer()).get('/api/v1/setup').expect(200);
|
||||
expect(status.body.status).toBe('required');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface FakeSmtpServer {
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
} from '@dorfteich/shared';
|
||||
import { User } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { SessionsService } from '../auth/sessions.service';
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
@ -70,14 +71,23 @@ export class SetupService implements OnModuleInit {
|
||||
if (!(await this.state.isPending())) return;
|
||||
|
||||
// Fails the boot loudly on invalid values — a half-seeded instance
|
||||
// would be much harder to diagnose than a startup error.
|
||||
const input = setupAdminInputSchema.parse({
|
||||
// would be much harder to diagnose than a startup error. Translated
|
||||
// into operator terms first: the raw ZodError names schema fields and
|
||||
// i18n keys, not the SETUP_* variable to fix (issue #325).
|
||||
const parsed = setupAdminInputSchema.safeParse({
|
||||
username: env.SETUP_ADMIN_USERNAME,
|
||||
email: env.SETUP_ADMIN_EMAIL,
|
||||
password: env.SETUP_ADMIN_PASSWORD,
|
||||
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
|
||||
locale: env.SETUP_DEFAULT_LOCALE,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Pre-seeding failed: ${describePreseedIssues(parsed.error)}. ` +
|
||||
'Fix .env and recreate the api container.',
|
||||
);
|
||||
}
|
||||
const input = parsed.data;
|
||||
const admin = await this.createAdmin(input);
|
||||
if (env.SETUP_INSTANCE_NAME) {
|
||||
await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id);
|
||||
@ -223,3 +233,27 @@ export class SetupService implements OnModuleInit {
|
||||
return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** The env variable behind each schema field of the pre-seeded admin. */
|
||||
const PRESEED_FIELD_TO_ENV: Record<string, string> = {
|
||||
username: 'SETUP_ADMIN_USERNAME',
|
||||
email: 'SETUP_ADMIN_EMAIL',
|
||||
password: 'SETUP_ADMIN_PASSWORD',
|
||||
displayName: 'SETUP_ADMIN_DISPLAY_NAME',
|
||||
locale: 'SETUP_DEFAULT_LOCALE',
|
||||
};
|
||||
|
||||
function describePreseedIssues(error: ZodError): string {
|
||||
return error.issues
|
||||
.map((issue) => {
|
||||
const variable = PRESEED_FIELD_TO_ENV[String(issue.path[0])] ?? String(issue.path[0]);
|
||||
if (issue.code === 'too_small' && issue.type === 'string') {
|
||||
return `${variable} must be at least ${issue.minimum} characters`;
|
||||
}
|
||||
if (issue.code === 'invalid_string' && issue.validation === 'email') {
|
||||
return `${variable} is not a valid e-mail address`;
|
||||
}
|
||||
return `${variable} is invalid (${issue.message})`;
|
||||
})
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Module, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { BrandingModule } from '../branding/branding.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { PagesModule } from '../pages/pages.module';
|
||||
@ -18,6 +19,7 @@ const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BrandingModule,
|
||||
CommonModule,
|
||||
PondsModule,
|
||||
QuotasModule,
|
||||
|
||||
@ -4,6 +4,7 @@ import { User } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { BrandingService } from '../branding/branding.service';
|
||||
import { ClockService } from '../common/clock.service';
|
||||
import { SearchProvider } from '../search/search.provider';
|
||||
import { PagesService } from '../pages/pages.service';
|
||||
@ -32,6 +33,7 @@ export class TrashService {
|
||||
private readonly settings: InstanceSettingsService,
|
||||
private readonly quotas: QuotaService,
|
||||
private readonly storage: FileStorageService,
|
||||
private readonly branding: BrandingService,
|
||||
private readonly clock: ClockService,
|
||||
private readonly watches: WatchesService,
|
||||
private readonly audit: AuditService,
|
||||
@ -183,6 +185,9 @@ export class TrashService {
|
||||
for (const attachment of attachments) {
|
||||
await this.storage.delete(pondId, attachment.id);
|
||||
}
|
||||
// The pond's branding files (issue #307). The purge standard is absolute:
|
||||
// after it, nothing referencing the pond survives — rows OR files.
|
||||
await this.branding.removePondAssets(pondId);
|
||||
const pageIds = (
|
||||
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
|
||||
).map((page) => page.id);
|
||||
|
||||
@ -87,6 +87,9 @@ for (const scheme of SCHEMES) {
|
||||
await page.emulateMedia({ colorScheme: scheme });
|
||||
await page.goto('/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Einladungs-Abschnitt (issue #332) gerendert — sonst liefe der Scan
|
||||
// auch grün, wenn die Sektion gar nicht erscheint.
|
||||
await page.locator('.invitations').waitFor();
|
||||
await expectClean(page, `/settings (${scheme})`);
|
||||
|
||||
// Lizenzseite im selben Kontext (issue #304: sie trägt seit den
|
||||
@ -123,6 +126,11 @@ for (const scheme of SCHEMES) {
|
||||
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
|
||||
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
|
||||
await expectClean(page, `/admin (${scheme})`);
|
||||
// Anlage-Dialog (issue #331) im selben Kontext öffnen und mitscannen —
|
||||
// wieder KEIN eigener Test (Rate-Limit-Lehre aus #301).
|
||||
await page.locator('.user-manager__create').click();
|
||||
await page.locator('.create-user-dialog').waitFor();
|
||||
await expectClean(page, `/admin Anlage-Dialog (${scheme})`);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
55
apps/web/e2e/admin-settings.spec.ts
Normal file
55
apps/web/e2e/admin-settings.spec.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { contextForUser } from './helpers';
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||
|
||||
/**
|
||||
* The general admin settings card saves THROUGH THE FORM (issue #322).
|
||||
*
|
||||
* This must drive the UI, not the api: the bug it fences was invisible to
|
||||
* every api-level test — react-hook-form nested the dotted field names on
|
||||
* input, the strict PATCH schema rejected the body, and the form looked
|
||||
* fine while never saving. Verified end to end: success message, the value
|
||||
* survives a full reload, the api returns it, and the TopBar picks it up
|
||||
* without a reload (branding query invalidation).
|
||||
*/
|
||||
test('instance name changed in the general settings form persists', async ({ browser }) => {
|
||||
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||
const before = (
|
||||
(await (await admin.request.get('/api/v1/admin/settings')).json()) as Record<string, unknown>
|
||||
)['instance.name'] as string;
|
||||
const newName = `Renamed ${Date.now()}`;
|
||||
|
||||
const nameLabel = /^(Instance name|Name der Instanz)$/;
|
||||
const page = await admin.newPage();
|
||||
try {
|
||||
await page.goto('/admin');
|
||||
const generalCard = page
|
||||
.locator('section.settings-section')
|
||||
.filter({ has: page.getByLabel(nameLabel) });
|
||||
await page.getByLabel(nameLabel).fill(newName);
|
||||
await generalCard.getByRole('button', { name: /^(Save|Speichern)$/ }).click();
|
||||
// Scoped to the card: the page has several forms with status regions.
|
||||
await expect(generalCard.getByRole('status')).toHaveText(/^(Saved\.|Gespeichert\.)$/);
|
||||
|
||||
// The TopBar and the document title show the new name without a reload —
|
||||
// the save invalidates the branding query both read from (issue #323).
|
||||
await expect(page.locator('.topbar__brand')).toHaveText(newName);
|
||||
await expect(page).toHaveTitle(new RegExp(`${newName}$`));
|
||||
|
||||
// The proof the form really persisted: the value survives a reload and
|
||||
// the api returns it.
|
||||
await page.reload();
|
||||
await expect(page.getByLabel(nameLabel)).toHaveValue(newName);
|
||||
const stored = (
|
||||
(await (await admin.request.get('/api/v1/admin/settings')).json()) as Record<string, unknown>
|
||||
)['instance.name'];
|
||||
expect(stored).toBe(newName);
|
||||
} finally {
|
||||
await admin.request.patch('/api/v1/admin/settings', {
|
||||
data: { 'instance.name': before },
|
||||
});
|
||||
await admin.close();
|
||||
}
|
||||
});
|
||||
@ -44,3 +44,43 @@ test('disabling a user in the admin UI blocks their login, enabling restores it'
|
||||
await admin.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Direct account creation (issue #331): the dialog creates an active account
|
||||
* — the new user logs in immediately, no verification hop. The account stays
|
||||
* in the e2e database; the unique name keeps reruns independent.
|
||||
*/
|
||||
test('creating a user in the admin UI yields an account that can log in at once', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||
const username = `created-${Date.now()}`;
|
||||
const password = 'ein sicheres anfangspasswort';
|
||||
|
||||
const page = await admin.newPage();
|
||||
await page.goto('/admin');
|
||||
await page.locator('.user-manager__create').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel(/username|benutzername/i).fill(username);
|
||||
await dialog.getByLabel(/e-mail/i).fill(`${username}@example.org`);
|
||||
await dialog.getByLabel(/display name|anzeigename/i).fill('Created via UI');
|
||||
await dialog.getByLabel(/initial password|anfangspasswort/i).fill(password);
|
||||
await dialog.getByRole('button', { name: /^create$|^anlegen$/i }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
// The list refetches; the fresh account is findable.
|
||||
await page.locator('.user-manager__search').fill(username);
|
||||
const row = page.locator(`.user-row[data-username="${username}"]`);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator('.user-row__status')).toHaveText(/active|aktiv/i);
|
||||
await admin.close();
|
||||
|
||||
// No verification mail hop: login works right away.
|
||||
const ctx = await request.newContext({ baseURL: BASE_URL });
|
||||
const res = await ctx.post('/api/v1/auth/login', {
|
||||
data: { usernameOrEmail: username, password },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
@ -60,6 +60,155 @@ test('typing persists across reload and undo/redo work', async ({ browser }) =>
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('gap cursor reaches positions before and after a lone table (issue #335)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E Gapcursor ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||
const status = page.locator('.editor-connection');
|
||||
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
|
||||
|
||||
const content = page.locator('.ProseMirror');
|
||||
await content.click();
|
||||
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
|
||||
await expect(content.locator('table')).toBeVisible();
|
||||
// Inserting into the empty page replaces the placeholder paragraph — the
|
||||
// table really is the only block, which is the situation of issue #335.
|
||||
await expect(content.locator(':scope > p')).toHaveCount(0);
|
||||
// Right after the insert the collab sync can still swallow a click's
|
||||
// selection update; interact only against a settled editor (established
|
||||
// pattern, see a11y.spec.ts). The typed markers below verify each click
|
||||
// really placed the cursor where the locator points.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Keyboard only: ArrowUp from the first cell lands on the gap cursor
|
||||
// before the table; typing there materializes a paragraph.
|
||||
await content.locator('th').first().click();
|
||||
await page.keyboard.type('in');
|
||||
await expect(content.locator('th').first()).toHaveText('in');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
||||
await page.keyboard.type('above');
|
||||
await expect(content.locator(':scope > :first-child')).toHaveText('above');
|
||||
|
||||
// Same for the position after the table.
|
||||
await content.locator('td').last().click();
|
||||
await page.keyboard.type('z');
|
||||
await expect(content.locator('td').last()).toHaveText('z');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
||||
await page.keyboard.type('below');
|
||||
await expect(content.locator(':scope > :last-child')).toHaveText('below');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('cells can be merged and split from the toolbar (issue #337)', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E MergeSplit ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||
const status = page.locator('.editor-connection');
|
||||
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
|
||||
|
||||
const content = page.locator('.ProseMirror');
|
||||
await content.click();
|
||||
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
|
||||
await expect(content.locator('table')).toBeVisible();
|
||||
|
||||
const mergeButton = page.getByRole('button', { name: /merge cells|zellen verbinden/i });
|
||||
const splitButton = page.getByRole('button', { name: /split cell|zelle teilen/i });
|
||||
await expect(mergeButton).toBeDisabled();
|
||||
await expect(splitButton).toBeDisabled();
|
||||
// Settle before clicking into cells — see the gap cursor test.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Extending the selection across the cell border turns it into a cell
|
||||
// selection (prosemirror-tables), which is what merge operates on.
|
||||
// Shift+Click, not Shift+ArrowRight: a keypress fired in the same tick as
|
||||
// the preceding click races the editor's post-click rendering and gets
|
||||
// dropped — no human types that fast (works fine interactively).
|
||||
await content.locator('td').first().click();
|
||||
await content
|
||||
.locator('td')
|
||||
.nth(1)
|
||||
.click({ modifiers: ['Shift'] });
|
||||
await expect(content.locator('.selectedCell')).toHaveCount(2);
|
||||
await expect(mergeButton).toBeEnabled();
|
||||
await mergeButton.click();
|
||||
await expect(content.locator('td[colspan="2"]')).toHaveCount(1);
|
||||
|
||||
// Splitting the merged cell restores the row's full cell count.
|
||||
await content.locator('td[colspan="2"]').click();
|
||||
await expect(splitButton).toBeEnabled();
|
||||
await splitButton.click();
|
||||
await expect(content.locator('td[colspan="2"]')).toHaveCount(0);
|
||||
await expect(content.locator('tr').nth(1).locator('td')).toHaveCount(3);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('Tab navigates table cells, extends the table, and never traps focus (issue #338)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E TableTab ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||
const status = page.locator('.editor-connection');
|
||||
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
|
||||
|
||||
const content = page.locator('.ProseMirror');
|
||||
await content.click();
|
||||
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
|
||||
const rows = content.locator('tr');
|
||||
await expect(rows).toHaveCount(3);
|
||||
// Settle before clicking into cells — see the gap cursor test.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Tab moves to the next cell, Shift+Tab back. Typed markers prove where
|
||||
// the cursor really is (the typing assertions also settle the editor
|
||||
// between keypresses — see the merge test on click/key races).
|
||||
await content.locator('th').first().click();
|
||||
await page.keyboard.type('one');
|
||||
await expect(content.locator('th').first()).toHaveText('one');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.type('two');
|
||||
await expect(content.locator('th').nth(1)).toHaveText('two');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await page.keyboard.type('back');
|
||||
await expect(content.locator('th').first()).toContainText('back');
|
||||
|
||||
// Tab in the last cell appends a row and moves into it (Word behavior).
|
||||
const lastCell = rows.nth(2).locator('td').nth(2);
|
||||
await lastCell.click();
|
||||
await page.keyboard.type('z');
|
||||
await expect(lastCell).toHaveText('z');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(rows).toHaveCount(4);
|
||||
await page.keyboard.type('new');
|
||||
await expect(rows.nth(3).locator('td').first()).toHaveText('new');
|
||||
|
||||
// No keyboard trap (WCAG 2.1.2): Escape works from EVERY cell (the gap
|
||||
// cursor is only reachable per arrow key from edge cells) and places the
|
||||
// cursor after the table; once outside, Tab leaves the editor entirely.
|
||||
// The mechanism is announced via the editor's aria-describedby hint.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(content).not.toBeFocused();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`);
|
||||
|
||||
93
apps/web/e2e/invitations.spec.ts
Normal file
93
apps/web/e2e/invitations.spec.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { contextForUser, latestMailFor, tokenFromMail } from './helpers';
|
||||
|
||||
/**
|
||||
* Peer invitations (issue #332), the full loop through the UI: a user
|
||||
* invites an address, registration is closed, the invitee registers
|
||||
* through the mailed link anyway, verifies, and the inviter sees the
|
||||
* invitation accepted. Needs Mailpit like the auth pack.
|
||||
*/
|
||||
const MAILPIT_URL = process.env.E2E_MAILPIT_URL;
|
||||
test.skip(!MAILPIT_URL, 'requires a Mailpit instance (E2E_MAILPIT_URL)');
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||
|
||||
test('invite -> closed registration -> signup through the link -> accepted', async ({
|
||||
browser,
|
||||
page,
|
||||
}) => {
|
||||
const stamp = Date.now().toString(36);
|
||||
const invitee = `invited-${stamp}@dorfteich.test`;
|
||||
|
||||
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||
const inviter = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
await admin.request.patch('/api/v1/admin/settings', {
|
||||
data: { 'auth.registrationMode': 'closed' },
|
||||
});
|
||||
try {
|
||||
// Invite through the settings UI.
|
||||
const settingsPage = await inviter.newPage();
|
||||
await settingsPage.goto('/settings');
|
||||
const section = settingsPage.locator('.invitations');
|
||||
await section.getByLabel(/e-mail/i).fill(invitee);
|
||||
await section.getByRole('button', { name: /^(invite|einladen)$/i }).click();
|
||||
await expect(section.locator('.invitations__sent')).toHaveText(/sent|verschickt/i);
|
||||
const row = section.locator(`.invitation-row[data-email="${invitee}"]`);
|
||||
await expect(row.locator('.invitation-row__status')).toHaveText(/open|offen/i);
|
||||
|
||||
// Plain signup is closed…
|
||||
await page.goto('/signup');
|
||||
await expect(page.locator('.form-banner')).toHaveText(/closed|geschlossen/i);
|
||||
|
||||
// …but the mailed link opens the form, inviter banner and prefill included.
|
||||
const mail = await latestMailFor(MAILPIT_URL!, invitee);
|
||||
const invitationToken = /invitation=([A-Za-z0-9_-]+)/.exec(mail.text)?.[1];
|
||||
expect(invitationToken).toBeTruthy();
|
||||
await page.goto(`/signup?invitation=${invitationToken}`);
|
||||
await expect(page.locator('.signup-invitation__banner')).toBeVisible();
|
||||
await expect(page.getByLabel(/e-mail/i)).toHaveValue(invitee);
|
||||
const username = `invited-${stamp}`;
|
||||
await page.getByLabel(/username|benutzername/i).fill(username);
|
||||
await page.getByLabel(/display name|anzeigename/i).fill('Invited Guest');
|
||||
await page.getByLabel(/^password|^passwort/i).fill('ein einladungs passwort 1');
|
||||
await page.getByRole('button', { name: /register|registrieren/i }).click();
|
||||
await expect(page.getByRole('heading', { name: /inbox|postfach/i })).toBeVisible();
|
||||
|
||||
// The usual verification still applies (the link proves nothing about
|
||||
// the mailbox). Two mails went to this address — poll for the second.
|
||||
let verifyToken = '';
|
||||
await expect(async () => {
|
||||
const verifyMail = await latestMailFor(MAILPIT_URL!, invitee);
|
||||
expect(verifyMail.text).toContain('/verify-email');
|
||||
verifyToken = tokenFromMail(verifyMail.text);
|
||||
}).toPass();
|
||||
await page.goto(`/verify-email?token=${verifyToken}`);
|
||||
await expect(page.getByRole('heading', { name: /confirmed|bestätigt/i })).toBeVisible();
|
||||
|
||||
// The inviter sees the acceptance; the used link is dead.
|
||||
await settingsPage.reload();
|
||||
await expect(
|
||||
settingsPage
|
||||
.locator(`.invitation-row[data-email="${invitee}"]`)
|
||||
.locator('.invitation-row__status'),
|
||||
).toHaveText(/accepted|angenommen/i);
|
||||
await page.goto(`/signup?invitation=${invitationToken}`);
|
||||
await expect(page.locator('.signup-invitation__invalid')).toBeVisible();
|
||||
|
||||
// Revoke flow through the UI: a second invitation dies by revoke.
|
||||
const second = `revoked-${stamp}@dorfteich.test`;
|
||||
await section.getByLabel(/e-mail/i).fill(second);
|
||||
await section.getByRole('button', { name: /^(invite|einladen)$/i }).click();
|
||||
const secondRow = section.locator(`.invitation-row[data-email="${second}"]`);
|
||||
await expect(secondRow.locator('.invitation-row__status')).toHaveText(/open|offen/i);
|
||||
await secondRow.getByRole('button', { name: /revoke|widerrufen/i }).click();
|
||||
await expect(secondRow.locator('.invitation-row__status')).toHaveText(/revoked|widerrufen/i);
|
||||
} finally {
|
||||
await admin.request.patch('/api/v1/admin/settings', {
|
||||
data: { 'auth.registrationMode': 'open' },
|
||||
});
|
||||
await admin.close();
|
||||
await inviter.close();
|
||||
}
|
||||
});
|
||||
@ -108,6 +108,102 @@ test('a plain-text paste is not mangled into rich structure', async ({ browser }
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a Markdown table pasted with code-editor styling HTML becomes a table (issue #339)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TablePaste ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await enterEditMode(page);
|
||||
await page.locator('.ProseMirror').click();
|
||||
|
||||
// VS Code (copyWithSyntaxHighlighting) ships the plain text a second time
|
||||
// as styled div/span HTML — exactly the flavor that used to shadow the
|
||||
// Markdown conversion.
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('.ProseMirror');
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
|
||||
dataTransfer.setData(
|
||||
'text/html',
|
||||
'<meta charset="utf-8"><div style="color:#d4d4d4;background-color:#1e1e1e;">' +
|
||||
'<div><span style="color:#d4d4d4;">| A | B |</span></div>' +
|
||||
'<div><span style="color:#d4d4d4;">| --- | --- |</span></div>' +
|
||||
'<div><span style="color:#d4d4d4;">| 1 | 2 |</span></div></div>',
|
||||
);
|
||||
el!.dispatchEvent(
|
||||
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const content = page.locator('.ProseMirror');
|
||||
await expect(content.locator('table')).toHaveCount(1);
|
||||
await expect(content.locator('th').first()).toHaveText('A');
|
||||
await expect(content.locator('td').first()).toHaveText('1');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a Markdown table pasted into a code block stays verbatim text (issue #339)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E MD CodePaste ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await enterEditMode(page);
|
||||
await page.locator('.ProseMirror').click();
|
||||
await page.getByRole('button', { name: /code block|codeblock/i }).click();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('.ProseMirror');
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
|
||||
el!.dispatchEvent(
|
||||
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const content = page.locator('.ProseMirror');
|
||||
await expect(content.locator('table')).toHaveCount(0);
|
||||
await expect(content.locator('pre')).toContainText('| A | B |');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('typing a Markdown table header plus separator creates a table (issue #339)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TableType ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await enterEditMode(page);
|
||||
const content = page.locator('.ProseMirror');
|
||||
await content.click();
|
||||
|
||||
await page.keyboard.type('| Name | Rolle |');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('| --- | --- |');
|
||||
await expect(content).toContainText('| --- | --- |');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await expect(content.locator('table')).toHaveCount(1);
|
||||
await expect(content.locator('th').first()).toHaveText('Name');
|
||||
await expect(content).not.toContainText('| --- | --- |');
|
||||
|
||||
// The cursor lands in the table; Tab from the last header cell appends the
|
||||
// first body row (#338), so typing continues seamlessly.
|
||||
await page.keyboard.type('x');
|
||||
await expect(content.locator('th').first()).toContainText('x');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('page menu downloads the page as Markdown matching its content', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`);
|
||||
|
||||
@ -28,8 +28,9 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
|
||||
await expect(nav).toBeVisible();
|
||||
const links = nav.locator('.settings-nav__link');
|
||||
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
|
||||
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild).
|
||||
await expect(links).toHaveCount(9);
|
||||
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild),
|
||||
// 10 seit #332 (Einladungen).
|
||||
await expect(links).toHaveCount(10);
|
||||
|
||||
// Jump to the last section: it scrolls into view and becomes active.
|
||||
const last = links.last();
|
||||
|
||||
@ -34,7 +34,7 @@ test('mode marked: card, checkbox marking, and point-of-choice marking', async (
|
||||
// select value — compliant choice clears it, violating choice brings it
|
||||
// back, no save in between.
|
||||
const regField = page.locator('label.field', {
|
||||
has: page.locator('select[name="auth.registrationMode"]'),
|
||||
has: page.locator('select[name="registrationMode"]'),
|
||||
});
|
||||
const regSelect = regField.locator('select');
|
||||
await regSelect.selectOption('open');
|
||||
@ -72,7 +72,7 @@ test('mode hidden: rows disappear, notes mark the hiding, a11y clean', async ({
|
||||
|
||||
// Value-listed control: the compliant registration mode keeps only its
|
||||
// compliant choice (seed leaves it open = violating? then all options).
|
||||
const regSelect = page.locator('select[name="auth.registrationMode"]');
|
||||
const regSelect = page.locator('select[name="registrationMode"]');
|
||||
const regField = page.locator('label.field', { has: regSelect });
|
||||
const optionCount = await regSelect.locator('option').count();
|
||||
const marked = await regField.locator('.vs-nfd-mark').count();
|
||||
|
||||
@ -1,50 +1,57 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { logoUrl, useBranding } from './use-branding';
|
||||
import { useCurrentPondRoute } from '../layout/use-pond-route';
|
||||
import { logoUrl, usePondFavicon, useResolvedBranding } from './use-branding';
|
||||
import { usePondId } from './use-pond-id';
|
||||
|
||||
/**
|
||||
* The instance identity at the top of the sidebar (issue #306): the uploaded
|
||||
* logo as a link home, or the instance name as text when nothing is uploaded.
|
||||
* The identity at the top of the sidebar (issues #306/#307): the pond's own
|
||||
* logo when it has one, else the instance's, else the instance name as text.
|
||||
*
|
||||
* Its accessible name is the INSTANCE NAME, never "logo": for a screen reader
|
||||
* this is the link home, and a link's name has to say where it goes. The
|
||||
* images are therefore `alt=""` — the link is already named.
|
||||
* Its accessible name follows the LEVEL the logo came from — a pond logo is
|
||||
* named by the pond, an instance logo by the instance. For a screen reader
|
||||
* this is the link home, and a link's name has to say where it goes; keeping
|
||||
* the instance name on a pond logo would announce the wrong destination.
|
||||
*
|
||||
* Both variants are rendered and one is hidden by CSS (`:root[data-theme]`),
|
||||
* not by JavaScript: `theme-init.js` resolves the theme before first paint, so
|
||||
* the correct logo is the one painted rather than the one that appears after a
|
||||
* flash. Without a dark variant the light one carries both themes — the
|
||||
* operator's own asset, shown unchanged, rather than a substitute they did
|
||||
* not choose (the rule #307 extends to ponds).
|
||||
* the correct logo is the one painted. A logo set belongs to ONE level and is
|
||||
* never mixed across levels — see `resolveBranding`.
|
||||
*/
|
||||
export function BrandLogo(): React.JSX.Element | null {
|
||||
const branding = useBranding();
|
||||
if (!branding) return null;
|
||||
const { logo, logoDark, instanceName } = branding;
|
||||
const { pondSlug } = useCurrentPondRoute();
|
||||
const { pondId, pondName } = usePondId(pondSlug);
|
||||
const { resolved, instanceName, pondId: logoPond } = useResolvedBranding(pondId);
|
||||
usePondFavicon(pondId, resolved.faviconLevel === 'pond');
|
||||
|
||||
const name = resolved.logoLevel === 'pond' ? (pondName ?? instanceName) : instanceName;
|
||||
if (!instanceName && resolved.logoLevel === 'none') return null;
|
||||
|
||||
return (
|
||||
<Link to="/" className="brand-logo" aria-label={instanceName}>
|
||||
{logo ? (
|
||||
<Link to="/" className="brand-logo" aria-label={name}>
|
||||
{resolved.logo || resolved.logoDark ? (
|
||||
<>
|
||||
<img
|
||||
className={`brand-logo__img brand-logo__img--light${logoDark ? '' : ' brand-logo__img--both'}`}
|
||||
src={logoUrl('light', logo.hash)}
|
||||
width={logo.width}
|
||||
height={logo.height}
|
||||
alt=""
|
||||
/>
|
||||
{logoDark && (
|
||||
{resolved.logo && (
|
||||
<img
|
||||
className="brand-logo__img brand-logo__img--dark"
|
||||
src={logoUrl('dark', logoDark.hash)}
|
||||
width={logoDark.width}
|
||||
height={logoDark.height}
|
||||
className={`brand-logo__img brand-logo__img--light${resolved.logoDark ? '' : ' brand-logo__img--both'}`}
|
||||
src={logoUrl('light', resolved.logo.hash, logoPond)}
|
||||
width={resolved.logo.width}
|
||||
height={resolved.logo.height}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
{resolved.logoDark && (
|
||||
<img
|
||||
className={`brand-logo__img brand-logo__img--dark${resolved.logo ? '' : ' brand-logo__img--both'}`}
|
||||
src={logoUrl('dark', resolved.logoDark.hash, logoPond)}
|
||||
width={resolved.logoDark.width}
|
||||
height={resolved.logoDark.height}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="brand-logo__name">{instanceName}</span>
|
||||
<span className="brand-logo__name">{name}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { BrandingView } from '@dorfteich/shared';
|
||||
import { BrandingView, PondBranding, ResolvedBranding, resolveBranding } from '@dorfteich/shared';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { apiGet } from '../lib/api';
|
||||
|
||||
@ -21,9 +22,65 @@ export function useBranding(): BrandingView | undefined {
|
||||
}
|
||||
|
||||
/** URL of a logo variant, with the content hash so a replaced logo is never
|
||||
* served from cache. */
|
||||
export function logoUrl(variant: 'light' | 'dark', hash: string): string {
|
||||
return `/api/v1/branding/logo?variant=${variant}&v=${hash}`;
|
||||
* served from cache. `pondId` scopes it to a pond's own asset (issue #307);
|
||||
* the route never falls back on its own — the CALLER decided which level
|
||||
* applies, and a silent fallback here would mix variants across levels. */
|
||||
export function logoUrl(variant: 'light' | 'dark', hash: string, pondId?: string): string {
|
||||
const pond = pondId ? `&pond=${pondId}` : '';
|
||||
return `/api/v1/branding/logo?variant=${variant}&v=${hash}${pond}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The branding in force here: the pond's own, else the instance's, else the
|
||||
* default (issue #307). One helper, in shared, so api and web cannot drift.
|
||||
*/
|
||||
export function useResolvedBranding(pondId?: string): {
|
||||
resolved: ResolvedBranding;
|
||||
instanceName: string;
|
||||
/** Which level the logo came from — the asset URLs need the pond scope
|
||||
* exactly when the pond supplied it. */
|
||||
pondId?: string;
|
||||
} {
|
||||
const instance = useBranding();
|
||||
const pond = useQuery({
|
||||
queryKey: ['pond', pondId, 'branding'],
|
||||
queryFn: () => apiGet<PondBranding>(`/ponds/${pondId!}/branding`),
|
||||
enabled: Boolean(pondId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const base = instance ?? { logo: null, logoDark: null, favicon: null, instanceName: '' };
|
||||
const resolved = resolveBranding(base, pond.data ?? null);
|
||||
return {
|
||||
resolved,
|
||||
instanceName: base.instanceName,
|
||||
pondId: resolved.logoLevel === 'pond' ? pondId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the document's icon at the pond's favicon while a pond is open, and
|
||||
* back at the instance's on leaving (issue #307).
|
||||
*
|
||||
* Accepted and worth stating: the swap necessarily happens AFTER first paint,
|
||||
* so opening a pond link directly shows the instance favicon briefly before it
|
||||
* changes. Avoiding that would mean server-rendering index.html, which is
|
||||
* #179's territory and deliberately out of scope here. In a pinned tab — where
|
||||
* telling ponds apart matters most — the tab is already open, so the swap is
|
||||
* the normal case rather than the exception.
|
||||
*
|
||||
* Driven by the RESOLVED pond, never by the raw route parameter: an unreadable
|
||||
* or non-existent pond slug must not leave a stale icon in the tab.
|
||||
*/
|
||||
export function usePondFavicon(pondId: string | undefined, hasPondFavicon: boolean): void {
|
||||
useEffect(() => {
|
||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (!link) return undefined;
|
||||
const instanceHref = '/api/v1/branding/favicon';
|
||||
link.href = pondId && hasPondFavicon ? `${instanceHref}?pond=${pondId}` : instanceHref;
|
||||
return () => {
|
||||
link.href = instanceHref;
|
||||
};
|
||||
}, [pondId, hasPondFavicon]);
|
||||
}
|
||||
|
||||
export function useInvalidateBranding(): () => Promise<void> {
|
||||
|
||||
21
apps/web/src/branding/use-pond-id.ts
Normal file
21
apps/web/src/branding/use-pond-id.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { PondView } from '@dorfteich/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiGet } from '../lib/api';
|
||||
|
||||
/**
|
||||
* The current pond's id and name from its slug (issue #307).
|
||||
*
|
||||
* Shares the sidebar's query key, so the pond is fetched once. Returns
|
||||
* nothing for an unreadable or unknown slug — which is exactly why the
|
||||
* favicon swap is driven by this and not by the raw route parameter: a bad
|
||||
* slug must not leave a stale icon in the tab.
|
||||
*/
|
||||
export function usePondId(pondSlug: string | null): { pondId?: string; pondName?: string } {
|
||||
const pond = useQuery({
|
||||
queryKey: ['pond', pondSlug],
|
||||
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug!}`),
|
||||
enabled: Boolean(pondSlug),
|
||||
});
|
||||
return { pondId: pond.data?.id, pondName: pond.data?.name };
|
||||
}
|
||||
@ -127,6 +127,8 @@ export function Toolbar({
|
||||
canAddColumn: e.can().addColumnAfter(),
|
||||
canDeleteColumn: e.can().deleteColumn(),
|
||||
canDeleteTable: e.can().deleteTable(),
|
||||
canMergeCells: e.can().mergeCells(),
|
||||
canSplitCell: e.can().splitCell(),
|
||||
canToggleHeaderRow: e.can().toggleHeaderRow(),
|
||||
canUndo: e.can().undo(),
|
||||
canRedo: e.can().redo(),
|
||||
@ -270,7 +272,10 @@ export function Toolbar({
|
||||
disabled={!state.canDeleteColumn}
|
||||
onClick={() => editor.chain().focus().deleteColumn().run()}
|
||||
>
|
||||
⊟↕
|
||||
{/* Axis stripes + the × delete marker (already established by
|
||||
deleteTable's ⊠): the earlier ⊟↕/⊟↔ double arrows read as
|
||||
"resize/expand", not "delete" (issue #336). */}
|
||||
▥×
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t('toolbar.table.addRowBefore')}
|
||||
@ -291,7 +296,23 @@ export function Toolbar({
|
||||
disabled={!state.canDeleteRow}
|
||||
onClick={() => editor.chain().focus().deleteRow().run()}
|
||||
>
|
||||
⊟↔
|
||||
▤×
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t('toolbar.table.mergeCells')}
|
||||
disabled={!state.canMergeCells}
|
||||
onClick={() => editor.chain().focus().mergeCells().run()}
|
||||
>
|
||||
{/* Arrows collapsing onto / leaving a cell border: merge removes
|
||||
the border between selected cells, split restores it. */}
|
||||
→|←
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t('toolbar.table.splitCell')}
|
||||
disabled={!state.canSplitCell}
|
||||
onClick={() => editor.chain().focus().splitCell().run()}
|
||||
>
|
||||
←|→
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t('toolbar.table.toggleHeaderRow')}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { AnyExtension } from '@tiptap/core';
|
||||
|
||||
import { GapCursor } from './gap-cursor';
|
||||
import { MarkdownClipboard } from './markdown-clipboard';
|
||||
import { MarkdownTableInput } from './markdown-table-input';
|
||||
import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks';
|
||||
import { Image } from './nodes/image';
|
||||
import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
||||
@ -63,4 +65,6 @@ export const documentExtensions: AnyExtension[] = [
|
||||
Strikethrough,
|
||||
LinkMark,
|
||||
MarkdownClipboard,
|
||||
MarkdownTableInput,
|
||||
GapCursor,
|
||||
];
|
||||
|
||||
20
apps/web/src/editor/gap-cursor.ts
Normal file
20
apps/web/src/editor/gap-cursor.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { gapCursor } from '@tiptap/pm/gapcursor';
|
||||
|
||||
/**
|
||||
* Cursor position adjacent to block nodes that offer no text position of
|
||||
* their own — without it a table (or code block, image, …) as the page's
|
||||
* first, last, or only block is unreachable from before/after, and no
|
||||
* paragraph can be created there (issue #335). Wraps prosemirror-gapcursor,
|
||||
* which also handles the arrow-key navigation into the gap positions; the
|
||||
* bar itself is styled in `styles/base.css` (`.ProseMirror-gapcursor`)
|
||||
* because the upstream package does not ship its stylesheet through this
|
||||
* entry point.
|
||||
*/
|
||||
export const GapCursor = Extension.create({
|
||||
name: 'gapCursor',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [gapCursor()];
|
||||
},
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { looksLikeMarkdown } from './markdown-clipboard';
|
||||
import { htmlIsStyledPlainText, looksLikeMarkdown } from './markdown-clipboard';
|
||||
|
||||
describe('looksLikeMarkdown (issue #30)', () => {
|
||||
it('recognizes a heading + list document', () => {
|
||||
@ -31,3 +32,21 @@ describe('looksLikeMarkdown (issue #30)', () => {
|
||||
expect(looksLikeMarkdown(' \n ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlIsStyledPlainText (issue #339)', () => {
|
||||
it('recognizes VS-Code-style syntax-highlighting HTML as styled plain text', () => {
|
||||
const vsCode =
|
||||
'<meta charset="utf-8"><div style="color:#d4d4d4;background-color:#1e1e1e;">' +
|
||||
'<div><span style="color:#d4d4d4;">| A | B |</span></div>' +
|
||||
'<div><span>| --- | --- |</span></div></div>';
|
||||
expect(htmlIsStyledPlainText(vsCode)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps rich-text clipboard HTML on the HTML paste path', () => {
|
||||
expect(htmlIsStyledPlainText('<table><tr><td>a</td></tr></table>')).toBe(false);
|
||||
expect(htmlIsStyledPlainText('<p><strong>bold</strong> prose</p>')).toBe(false);
|
||||
expect(htmlIsStyledPlainText('<ul><li>one</li></ul>')).toBe(false);
|
||||
expect(htmlIsStyledPlainText('<p><a href="https://example.org">link</a></p>')).toBe(false);
|
||||
expect(htmlIsStyledPlainText('<pre><code>x</code></pre>')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -26,6 +26,25 @@ export function looksLikeMarkdown(text: string): boolean {
|
||||
return matches.length >= 2;
|
||||
}
|
||||
|
||||
/** Elements whose presence means the clipboard HTML carries real structure
|
||||
* or semantics that ProseMirror's HTML paste should interpret. */
|
||||
const STRUCTURAL_HTML =
|
||||
'table, ul, ol, li, h1, h2, h3, h4, h5, h6, blockquote, pre, code, a, img, b, strong, i, em, u, s';
|
||||
|
||||
/**
|
||||
* Code editors (VS Code with copyWithSyntaxHighlighting, similar tools) put
|
||||
* an HTML flavor on the clipboard that is nothing but the plain text wrapped
|
||||
* in styled div/span containers. Treating that as "real HTML" made the paste
|
||||
* ignore the Markdown heuristic below, so a Markdown table copied out of
|
||||
* VS Code arrived as verbatim text while the same text from a plain editor
|
||||
* converted fine (issue #339). Only HTML without any structural element is
|
||||
* declared equivalent to the plain text — anything from a rich-text source
|
||||
* keeps going through ProseMirror's own HTML paste.
|
||||
*/
|
||||
export function htmlIsStyledPlainText(html: string): boolean {
|
||||
return new DOMParser().parseFromString(html, 'text/html').querySelector(STRUCTURAL_HTML) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown on the clipboard, both ways (issue #30, ADR 0004/0009): copying
|
||||
* puts Markdown on `text/plain` alongside the browser's own HTML (so
|
||||
@ -65,8 +84,11 @@ export const MarkdownClipboard = Extension.create({
|
||||
}
|
||||
},
|
||||
handlePaste(view, event) {
|
||||
// Inside a code block pasted text is code, never a document —
|
||||
// converting there would split the block around rich nodes.
|
||||
if (view.state.selection.$from.parent.type.spec.code) return false;
|
||||
const html = event.clipboardData?.getData('text/html');
|
||||
if (html && html.trim() !== '') return false;
|
||||
if (html && html.trim() !== '' && !htmlIsStyledPlainText(html)) return false;
|
||||
const text = event.clipboardData?.getData('text/plain');
|
||||
if (!text || !looksLikeMarkdown(text)) return false;
|
||||
|
||||
|
||||
68
apps/web/src/editor/markdown-table-input.ts
Normal file
68
apps/web/src/editor/markdown-table-input.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { markdownToDoc } from '@dorfteich/shared';
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import { Plugin, Selection } from '@tiptap/pm/state';
|
||||
|
||||
/** A `| … |` pipe row — the same signal `looksLikeMarkdown` uses. */
|
||||
const PIPE_ROW = /^\|.+\|\s*$/;
|
||||
|
||||
/** The GFM header separator (`| --- | :--- |`…). Three dashes minimum keeps
|
||||
* accidental short rows like `|-|` from ever triggering a conversion. */
|
||||
const SEPARATOR_ROW = /^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/;
|
||||
|
||||
/**
|
||||
* Hand-typed Markdown tables (issue #339): pressing Enter at the end of a
|
||||
* separator row whose previous sibling is a pipe row replaces the two
|
||||
* paragraphs with a real table. TipTap input rules cannot express this —
|
||||
* they only see text inside a single textblock, and a table needs two.
|
||||
* Conversion is refused inside existing tables (the schema would allow the
|
||||
* nested table, the reader could not make sense of it). Body rows are then
|
||||
* typed cell-wise — Tab in the last cell appends a row (#338).
|
||||
*/
|
||||
export const MarkdownTableInput = Extension.create({
|
||||
name: 'markdownTableInput',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handleKeyDown(view, event) {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey)
|
||||
return false;
|
||||
const { $from, empty } = view.state.selection;
|
||||
if (!empty || $from.parent.type.name !== 'paragraph') return false;
|
||||
if ($from.parentOffset !== $from.parent.content.size) return false;
|
||||
if (!SEPARATOR_ROW.test($from.parent.textContent)) return false;
|
||||
for (let depth = $from.depth - 1; depth > 0; depth -= 1) {
|
||||
if ($from.node(depth).type.spec.tableRole) return false;
|
||||
}
|
||||
const container = $from.node($from.depth - 1);
|
||||
const index = $from.index($from.depth - 1);
|
||||
if (index === 0) return false;
|
||||
const headerRow = container.child(index - 1);
|
||||
if (headerRow.type.name !== 'paragraph' || !PIPE_ROW.test(headerRow.textContent))
|
||||
return false;
|
||||
|
||||
let table: ProseMirrorNode;
|
||||
try {
|
||||
const parsed = markdownToDoc(`${headerRow.textContent}\n${$from.parent.textContent}`);
|
||||
if (parsed.childCount !== 1 || parsed.firstChild?.type.name !== 'table') return false;
|
||||
// Re-hydrated against the live schema — same identity dance as
|
||||
// in markdown-clipboard.ts.
|
||||
table = ProseMirrorNode.fromJSON(view.state.schema, parsed.firstChild.toJSON());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const start = $from.before($from.depth) - headerRow.nodeSize;
|
||||
const end = $from.after($from.depth);
|
||||
const tr = view.state.tr.replaceWith(start, end, table);
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(start), 1));
|
||||
view.dispatch(tr.scrollIntoView());
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@ -1,4 +1,6 @@
|
||||
import { Node } from '@tiptap/core';
|
||||
import { GapCursor } from '@tiptap/pm/gapcursor';
|
||||
import { Selection } from '@tiptap/pm/state';
|
||||
import type { Node as PMNode, Schema } from 'prosemirror-model';
|
||||
import {
|
||||
addColumnAfter,
|
||||
@ -8,6 +10,9 @@ import {
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
deleteTable,
|
||||
goToNextCell,
|
||||
mergeCells,
|
||||
splitCell,
|
||||
tableEditing,
|
||||
toggleHeaderRow,
|
||||
} from 'prosemirror-tables';
|
||||
@ -34,6 +39,10 @@ declare module '@tiptap/core' {
|
||||
addRowAfter: () => ReturnType;
|
||||
deleteRow: () => ReturnType;
|
||||
deleteTable: () => ReturnType;
|
||||
mergeCells: () => ReturnType;
|
||||
splitCell: () => ReturnType;
|
||||
goToNextCell: () => ReturnType;
|
||||
goToPreviousCell: () => ReturnType;
|
||||
toggleHeaderRow: () => ReturnType;
|
||||
};
|
||||
}
|
||||
@ -65,6 +74,37 @@ export const Table = Node.create({
|
||||
addProseMirrorPlugins() {
|
||||
return [tableEditing()];
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// Word-style navigation (issue #338): Tab moves cell-wise and appends
|
||||
// a new row from the last cell. Outside a table every branch returns
|
||||
// false, so Tab keeps its browser default (focus moves on) and the
|
||||
// editor is no keyboard trap — from inside a table the arrow keys
|
||||
// lead out via the gap cursor (#335), then Tab leaves the editor.
|
||||
Tab: () => {
|
||||
if (this.editor.commands.goToNextCell()) return true;
|
||||
if (!this.editor.can().addRowAfter()) return false;
|
||||
return this.editor.chain().addRowAfter().goToNextCell().run();
|
||||
},
|
||||
'Shift-Tab': () => this.editor.commands.goToPreviousCell(),
|
||||
// The documented exit (aria-describedby hint, #338): the gap cursor is
|
||||
// only reachable per arrow key from the table's edge cells, so Escape
|
||||
// is the exit that works from EVERY cell. Falls back to a gap cursor
|
||||
// when no textblock follows the table (#335 guarantees the position).
|
||||
Escape: () =>
|
||||
this.editor.commands.command(({ state, dispatch }) => {
|
||||
const { $head } = state.selection;
|
||||
for (let depth = $head.depth; depth > 0; depth -= 1) {
|
||||
if ($head.node(depth).type.spec.tableRole !== 'table') continue;
|
||||
const $after = state.doc.resolve($head.after(depth));
|
||||
const selection = Selection.findFrom($after, 1, true) ?? new GapCursor($after);
|
||||
if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
};
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
@ -101,6 +141,22 @@ export const Table = Node.create({
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
deleteTable(state, dispatch),
|
||||
mergeCells:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
mergeCells(state, dispatch),
|
||||
splitCell:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
splitCell(state, dispatch),
|
||||
goToNextCell:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
goToNextCell(1)(state, dispatch),
|
||||
goToPreviousCell:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
goToNextCell(-1)(state, dispatch),
|
||||
toggleHeaderRow:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
|
||||
@ -10,6 +10,7 @@ import deFiles from '@dorfteich/shared/i18n/de/files.json';
|
||||
import deFont from '@dorfteich/shared/i18n/de/font.json';
|
||||
import deGraph from '@dorfteich/shared/i18n/de/graph.json';
|
||||
import deImport from '@dorfteich/shared/i18n/de/import.json';
|
||||
import deInvitations from '@dorfteich/shared/i18n/de/invitations.json';
|
||||
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
|
||||
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
||||
import deLinks from '@dorfteich/shared/i18n/de/links.json';
|
||||
@ -39,6 +40,7 @@ import enFiles from '@dorfteich/shared/i18n/en/files.json';
|
||||
import enFont from '@dorfteich/shared/i18n/en/font.json';
|
||||
import enGraph from '@dorfteich/shared/i18n/en/graph.json';
|
||||
import enImport from '@dorfteich/shared/i18n/en/import.json';
|
||||
import enInvitations from '@dorfteich/shared/i18n/en/invitations.json';
|
||||
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
||||
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
||||
import enLinks from '@dorfteich/shared/i18n/en/links.json';
|
||||
@ -85,6 +87,7 @@ void i18n
|
||||
font: enFont,
|
||||
graph: enGraph,
|
||||
import: enImport,
|
||||
invitations: enInvitations,
|
||||
labels: enLabels,
|
||||
legal: enLegal,
|
||||
links: enLinks,
|
||||
@ -116,6 +119,7 @@ void i18n
|
||||
font: deFont,
|
||||
graph: deGraph,
|
||||
import: deImport,
|
||||
invitations: deInvitations,
|
||||
labels: deLabels,
|
||||
legal: deLegal,
|
||||
links: deLinks,
|
||||
|
||||
@ -1,22 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useBranding } from '../branding/use-branding';
|
||||
|
||||
const APP_NAME = 'Dorfteich';
|
||||
|
||||
/**
|
||||
* Route-specific document title (issue #163, WCAG 2.4.2): joins the given
|
||||
* parts with the app name ("Page — Pond — Dorfteich"). Empty/undefined
|
||||
* parts with the instance name ("Page — Pond — My Wiki"). Empty/undefined
|
||||
* parts are skipped, so callers can pass still-loading data directly.
|
||||
* Falls back to the bare app name on unmount.
|
||||
* Falls back to the bare instance name on unmount.
|
||||
*
|
||||
* The trailing name is the OPERATOR'S instance name, not the product name
|
||||
* (issue #323) — same reasoning as the TopBar brand (issue #306). Until
|
||||
* the branding query resolves (or when it cannot, e.g. maintenance mode)
|
||||
* the shipped default keeps the title stable, so an untouched instance
|
||||
* reads exactly as before.
|
||||
*/
|
||||
export function useDocumentTitle(...parts: (string | null | undefined)[]): void {
|
||||
const joined = [...parts.filter(Boolean), APP_NAME].join(' — ');
|
||||
const appName = useBranding()?.instanceName.trim() || APP_NAME;
|
||||
const joined = [...parts.filter(Boolean), appName].join(' — ');
|
||||
useEffect(() => {
|
||||
document.title = joined;
|
||||
}, [joined]);
|
||||
useEffect(
|
||||
// On unmount only in effect: `joined` always changes with `appName`,
|
||||
// so the title effect above re-runs right after this cleanup.
|
||||
() => () => {
|
||||
document.title = APP_NAME;
|
||||
document.title = appName;
|
||||
},
|
||||
[],
|
||||
[appName],
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,10 +5,17 @@ import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { BRANDING_KEY } from '../branding/use-branding';
|
||||
import { Field, FormError, FormSuccess } from '../components/forms';
|
||||
import { SettingsLayout } from '../components/SettingsLayout';
|
||||
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
|
||||
import { apiGet, apiPatch } from '../lib/api';
|
||||
import {
|
||||
GENERAL_FORM_FIELDS,
|
||||
GeneralSettingsForm,
|
||||
toFormValues,
|
||||
toSettingsPatch,
|
||||
} from './admin-settings-form';
|
||||
import { BrandingManager } from './BrandingManager';
|
||||
import { CustomFontManager } from './CustomFontManager';
|
||||
import { PluginManager } from './PluginManager';
|
||||
@ -18,6 +25,7 @@ import { UserManager } from './UserManager';
|
||||
import { useDocumentTitle } from '../lib/use-document-title';
|
||||
interface InstanceSettings {
|
||||
'auth.registrationMode': 'open' | 'closed';
|
||||
'invitations.maxOpenPerUser': number;
|
||||
'instance.name': string;
|
||||
'instance.defaultLocale': 'de' | 'en';
|
||||
'quota.editorsPerPond': number;
|
||||
@ -51,15 +59,23 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
queryFn: () => apiGet<InstanceSettings>('/admin/settings'),
|
||||
});
|
||||
|
||||
const form = useForm<InstanceSettings>({ values: settings.data });
|
||||
// Dot-free field names with an explicit mapping to the dotted settings
|
||||
// keys — see admin-settings-form.ts for why the names must not contain
|
||||
// dots (issue #322).
|
||||
const form = useForm<GeneralSettingsForm>({
|
||||
values: settings.data ? toFormValues(settings.data) : undefined,
|
||||
});
|
||||
const vsNfd = useVsNfdMarking();
|
||||
|
||||
const onSubmit = form.handleSubmit(async (input) => {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
await apiPatch('/admin/settings', input);
|
||||
await apiPatch('/admin/settings', toSettingsPatch(input));
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
|
||||
// The TopBar takes the instance name from the public branding query;
|
||||
// without this it keeps the old name until its staleTime runs out.
|
||||
await queryClient.invalidateQueries({ queryKey: BRANDING_KEY });
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
@ -79,6 +95,10 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
<section className="settings-section">
|
||||
<h2>{t('settings:admin.general')}</h2>
|
||||
{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) ||
|
||||
vsNfd.hides(
|
||||
'invitations.maxOpenPerUser',
|
||||
settings.data['invitations.maxOpenPerUser'],
|
||||
) ||
|
||||
vsNfd.hides(
|
||||
'classification.newPageDefault',
|
||||
settings.data['classification.newPageDefault'],
|
||||
@ -91,37 +111,53 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
<FormError error={error} />
|
||||
<FormSuccess message={saved ? t('settings:admin.saved') : null} />
|
||||
<Field label={t('settings:admin.instanceName')}>
|
||||
<input type="text" {...form.register('instance.name')} />
|
||||
<input type="text" {...form.register('instanceName')} />
|
||||
</Field>
|
||||
<Field label={t('settings:admin.defaultLocale')}>
|
||||
<select {...form.register('instance.defaultLocale')}>
|
||||
<select {...form.register('defaultLocale')}>
|
||||
<option value="de">{t('settings:profile.locales.de')}</option>
|
||||
<option value="en">{t('settings:profile.locales.en')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label={t('settings:admin.registrationMode')}
|
||||
marking={vsNfd.markingFor(
|
||||
'auth.registrationMode',
|
||||
form.watch('auth.registrationMode'),
|
||||
)}
|
||||
marking={vsNfd.markingFor('auth.registrationMode', form.watch('registrationMode'))}
|
||||
>
|
||||
<select {...form.register('auth.registrationMode')}>
|
||||
<select {...form.register('registrationMode')}>
|
||||
{!vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) && (
|
||||
<option value="open">{t('settings:admin.registrationOpen')}</option>
|
||||
)}
|
||||
<option value="closed">{t('settings:admin.registrationClosed')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
{!vsNfd.hides(
|
||||
'invitations.maxOpenPerUser',
|
||||
settings.data['invitations.maxOpenPerUser'],
|
||||
) && (
|
||||
<Field
|
||||
label={t('settings:admin.invitationsMaxOpen')}
|
||||
hint={t('settings:admin.invitationsMaxOpenHelp')}
|
||||
marking={vsNfd.markingFor(
|
||||
'invitations.maxOpenPerUser',
|
||||
form.watch('invitationsMaxOpenPerUser'),
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
{...form.register('invitationsMaxOpenPerUser', { valueAsNumber: true })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label={t('settings:admin.newPageClassification')}
|
||||
hint={t('settings:admin.newPageClassificationHelp')}
|
||||
marking={vsNfd.markingFor(
|
||||
'classification.newPageDefault',
|
||||
form.watch('classification.newPageDefault'),
|
||||
form.watch('newPageClassification'),
|
||||
)}
|
||||
>
|
||||
<select {...form.register('classification.newPageDefault')}>
|
||||
<select {...form.register('newPageClassification')}>
|
||||
{!vsNfd.hides(
|
||||
'classification.newPageDefault',
|
||||
settings.data['classification.newPageDefault'],
|
||||
@ -136,12 +172,9 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
<Field
|
||||
label={t('settings:admin.uploadPolicy')}
|
||||
hint={t('settings:admin.uploadPolicyHelp')}
|
||||
marking={vsNfd.markingFor(
|
||||
'classification.uploadPolicy',
|
||||
form.watch('classification.uploadPolicy'),
|
||||
)}
|
||||
marking={vsNfd.markingFor('classification.uploadPolicy', form.watch('uploadPolicy'))}
|
||||
>
|
||||
<select {...form.register('classification.uploadPolicy')}>
|
||||
<select {...form.register('uploadPolicy')}>
|
||||
{!vsNfd.hides(
|
||||
'classification.uploadPolicy',
|
||||
settings.data['classification.uploadPolicy'],
|
||||
@ -160,15 +193,18 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
{(
|
||||
[
|
||||
'quota.editorsPerPond',
|
||||
'quota.readersPerPond',
|
||||
'quota.additionalPonds',
|
||||
'quota.storageBytes',
|
||||
'quota.maxFileBytes',
|
||||
'quotaEditorsPerPond',
|
||||
'quotaReadersPerPond',
|
||||
'quotaAdditionalPonds',
|
||||
'quotaStorageBytes',
|
||||
'quotaMaxFileBytes',
|
||||
] as const
|
||||
).map((key) => (
|
||||
<Field key={key} label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[key]}`)}>
|
||||
<input type="number" min={0} {...form.register(key, { valueAsNumber: true })} />
|
||||
).map((field) => (
|
||||
<Field
|
||||
key={field}
|
||||
label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[GENERAL_FORM_FIELDS[field]]}`)}
|
||||
>
|
||||
<input type="number" min={0} {...form.register(field, { valueAsNumber: true })} />
|
||||
</Field>
|
||||
))}
|
||||
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
||||
|
||||
140
apps/web/src/pages/InvitationsSection.tsx
Normal file
140
apps/web/src/pages/InvitationsSection.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import type { InvitationListView } from '@dorfteich/shared';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Field, FormError } from '../components/forms';
|
||||
import { apiDelete, apiGet, apiPost } from '../lib/api';
|
||||
|
||||
const INVITATIONS_QUERY_KEY = ['users', 'me', 'invitations'] as const;
|
||||
|
||||
/**
|
||||
* Peer invitations in the user settings (issue #332): invite an e-mail
|
||||
* address, see your invitations with their status, revoke open ones. The
|
||||
* quota line shows how many of the instance-wide per-user allowance are
|
||||
* in use; with a quota of 0 the section explains that inviting is off.
|
||||
*/
|
||||
export function InvitationsSection(): React.JSX.Element {
|
||||
const { t } = useTranslation('invitations');
|
||||
const queryClient = useQueryClient();
|
||||
const [email, setEmail] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: INVITATIONS_QUERY_KEY,
|
||||
queryFn: () => apiGet<InvitationListView>('/invitations'),
|
||||
});
|
||||
|
||||
const submit = async (event: React.FormEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSent(false);
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPost('/invitations', { email });
|
||||
setEmail('');
|
||||
setSent(true);
|
||||
await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY });
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (id: string): Promise<void> => {
|
||||
setError(null);
|
||||
await apiDelete(`/invitations/${id}`);
|
||||
await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY });
|
||||
};
|
||||
|
||||
const data = list.data;
|
||||
const disabled = data?.maxOpen === 0;
|
||||
|
||||
return (
|
||||
<section className="settings-section invitations">
|
||||
<h2>{t('section.title')}</h2>
|
||||
{disabled ? (
|
||||
<p>{t('section.disabled')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="invitations__intro">{t('section.intro')}</p>
|
||||
{data && (
|
||||
<p className="invitations__quota">
|
||||
{t('section.quota', { open: data.open, max: data.maxOpen })}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={(e) => void submit(e)} noValidate className="invitations__form">
|
||||
<FormError error={error} />
|
||||
{/* Scoped status region: a bare getByRole('status') must stay
|
||||
unambiguous for other specs (lesson from #304/legal). */}
|
||||
<p className="invitations__sent" role="status">
|
||||
{sent ? t('form.sent') : ''}
|
||||
</p>
|
||||
<Field label={t('form.email')}>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<button type="submit" className="button" disabled={busy || email.length === 0}>
|
||||
{t('form.submit')}
|
||||
</button>
|
||||
</form>
|
||||
{data && data.invitations.length === 0 && <p>{t('section.empty')}</p>}
|
||||
{data && data.invitations.length > 0 && (
|
||||
<div
|
||||
className="table-scroll"
|
||||
// A scroll container is only operable by keyboard once it is
|
||||
// focusable; role+name keep it from being an unlabelled stop.
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={t('section.title')}
|
||||
>
|
||||
<table className="table invitations__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('columns.email')}</th>
|
||||
<th>{t('columns.status')}</th>
|
||||
<th>{t('columns.created')}</th>
|
||||
<th>{t('columns.expires')}</th>
|
||||
<th>{t('columns.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.invitations.map((invitation) => (
|
||||
<tr
|
||||
key={invitation.id}
|
||||
className="invitation-row"
|
||||
data-email={invitation.email}
|
||||
>
|
||||
<td>{invitation.email}</td>
|
||||
<td className="invitation-row__status">{t(`status.${invitation.status}`)}</td>
|
||||
<td>{new Date(invitation.createdAt).toLocaleDateString()}</td>
|
||||
<td>{new Date(invitation.expiresAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
{invitation.status === 'pending' && (
|
||||
<button
|
||||
type="button"
|
||||
className="linklike invitation-row__revoke"
|
||||
onClick={() => void revoke(invitation.id)}
|
||||
>
|
||||
{t('actions.revoke')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -227,7 +227,9 @@ function PageEditor({
|
||||
attributes: {
|
||||
role: canEdit ? 'textbox' : 'document',
|
||||
'aria-label': t('contentLabel'),
|
||||
...(canEdit ? { 'aria-multiline': 'true' } : {}),
|
||||
...(canEdit
|
||||
? { 'aria-multiline': 'true', 'aria-describedby': 'editor-keyboard-hint' }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -361,6 +363,13 @@ function PageEditor({
|
||||
/>
|
||||
)}
|
||||
<EditorContent editor={editor} className="editor-content" />
|
||||
{/* Referenced via aria-describedby in edit mode: Tab is captured
|
||||
inside tables (#338), so the way out must be discoverable. */}
|
||||
{canEdit && (
|
||||
<p id="editor-keyboard-hint" className="visually-hidden">
|
||||
{t('keyboardHint')}
|
||||
</p>
|
||||
)}
|
||||
{canEdit && <WikilinkAutocomplete editor={editor} />}
|
||||
{canEdit && <MentionAutocomplete editor={editor} />}
|
||||
</div>
|
||||
|
||||
@ -21,6 +21,7 @@ import { StartPageSetting } from '../ponds/StartPageSetting';
|
||||
import { SidebarViewSetting } from '../layout/SidebarViewSetting';
|
||||
import { MemberManager } from '../members/MemberManager';
|
||||
import { DeletePondSection } from '../ponds/DeletePondSection';
|
||||
import { PondBrandingSection } from '../ponds/PondBrandingSection';
|
||||
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
||||
import { PondThemeSection } from '../theme/PondThemeSection';
|
||||
|
||||
@ -39,6 +40,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
||||
const { t: tMembers } = useTranslation('members');
|
||||
const { t: tErrors } = useTranslation('errors');
|
||||
const { t: tFiles } = useTranslation('files');
|
||||
const { t: tBranding } = useTranslation('branding');
|
||||
const { t: tExport } = useTranslation('export');
|
||||
const { t: tComments } = useTranslation('comments');
|
||||
const { t: tApiTokens } = useTranslation('apiTokens');
|
||||
@ -114,6 +116,8 @@ export function PondSettingsPage(): React.JSX.Element {
|
||||
pondSlug={pondSlug}
|
||||
theme={pond.data.settings.theme}
|
||||
/>
|
||||
<h3>{tBranding('admin.title')}</h3>
|
||||
<PondBrandingSection pondId={pond.data.id} />
|
||||
</section>
|
||||
)}
|
||||
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
||||
|
||||
@ -138,21 +138,26 @@ function QuotaRow({
|
||||
<td>{t(`keys.${line.key}`)}</td>
|
||||
<td>{line.instanceDefault}</td>
|
||||
<td className="quota-row__override">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="quota-row__input"
|
||||
value={draft}
|
||||
onChange={(e) => onDraft(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="linklike" onClick={onSet}>
|
||||
{t('overrides.set')}
|
||||
</button>
|
||||
{line.override !== null && (
|
||||
<button type="button" className="linklike" onClick={onClear}>
|
||||
{t('overrides.clear')}
|
||||
{/* Flex lives on the inner div: a td with display:flex stops behaving
|
||||
like a table cell and its bottom border no longer meets the row's
|
||||
(same fix as the user list's actions cell, #177). */}
|
||||
<div className="quota-row__override-inner">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="quota-row__input"
|
||||
value={draft}
|
||||
onChange={(e) => onDraft(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="linklike" onClick={onSet}>
|
||||
{t('overrides.set')}
|
||||
</button>
|
||||
)}
|
||||
{line.override !== null && (
|
||||
<button type="button" className="linklike" onClick={onClear}>
|
||||
{t('overrides.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="quota-row__effective">{line.effective}</td>
|
||||
<td className="quota-row__usage">
|
||||
|
||||
@ -17,6 +17,7 @@ import { SettingsLayout } from '../components/SettingsLayout';
|
||||
import { useDataExport } from '../export/use-data-export';
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
||||
import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
|
||||
import { InvitationsSection } from './InvitationsSection';
|
||||
import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
|
||||
import { WatchesSection } from '../watches/WatchesSection';
|
||||
|
||||
@ -45,6 +46,7 @@ export function SettingsPage(): React.JSX.Element {
|
||||
<PasswordSection />
|
||||
<SessionsSection />
|
||||
<WatchesSection />
|
||||
<InvitationsSection />
|
||||
<ApiTokensSection />
|
||||
<FeedTokensSection />
|
||||
<AppearanceSection />
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import type { AdminUserListView, AdminUserView } from '@dorfteich/shared';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { AdminCreateUserFormInput, AdminUserListView, AdminUserView } from '@dorfteich/shared';
|
||||
import { adminCreateUserSchema } from '@dorfteich/shared';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { MailCheck, ShieldMinus, ShieldPlus, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { IconButton } from '../components/IconButton';
|
||||
import { Field, FormError, applyFieldErrors } from '../components/forms';
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
||||
import { useDismissable } from '../lib/use-dismissable';
|
||||
import { useModalFocus } from '../lib/use-modal-focus';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@ -21,6 +27,8 @@ export function UserManager(): React.JSX.Element {
|
||||
const { user: me } = useAuth();
|
||||
const [q, setQ] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const createButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin', 'users', q, page],
|
||||
@ -42,6 +50,24 @@ export function UserManager(): React.JSX.Element {
|
||||
return (
|
||||
<section className="settings-section user-manager">
|
||||
<h2>{t('title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
ref={createButtonRef}
|
||||
className="button user-manager__create"
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
{t('create.button')}
|
||||
</button>
|
||||
{creating && (
|
||||
<CreateUserDialog
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={() => {
|
||||
setCreating(false);
|
||||
void query.refetch();
|
||||
}}
|
||||
returnFocusRef={createButtonRef}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
className="user-manager__search"
|
||||
type="search"
|
||||
@ -92,6 +118,98 @@ export function UserManager(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct account creation by a Site Admin (issue #331): same field rules as
|
||||
* self-registration, but the account is active immediately — no verification
|
||||
* mail hop. Field names stay flat (no dots) — react-hook-form treats dots as
|
||||
* path separators (#322).
|
||||
*/
|
||||
function CreateUserDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
returnFocusRef,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
returnFocusRef: React.RefObject<HTMLElement | null>;
|
||||
}): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation('users');
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
useDismissable(dialogRef, true, onClose);
|
||||
useModalFocus(dialogRef, returnFocusRef);
|
||||
|
||||
const form = useForm<AdminCreateUserFormInput>({
|
||||
resolver: zodResolver(adminCreateUserSchema) as Resolver<AdminCreateUserFormInput>,
|
||||
defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' },
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async (input) => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost('/admin/users', input);
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
applyFieldErrors(err, (name, fieldError) =>
|
||||
form.setError(name as keyof AdminCreateUserFormInput, fieldError),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div
|
||||
className="modal create-user-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<h2 className="modal__title" id={titleId}>
|
||||
{t('create.title')}
|
||||
</h2>
|
||||
<p>{t('create.intro')}</p>
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<FormError error={error} />
|
||||
<Field label={t('create.username')} error={form.formState.errors.username?.message}>
|
||||
<input type="text" autoComplete="off" {...form.register('username')} />
|
||||
</Field>
|
||||
<Field label={t('create.email')} error={form.formState.errors.email?.message}>
|
||||
<input type="email" autoComplete="off" {...form.register('email')} />
|
||||
</Field>
|
||||
<Field label={t('create.displayName')} error={form.formState.errors.displayName?.message}>
|
||||
<input type="text" autoComplete="off" {...form.register('displayName')} />
|
||||
</Field>
|
||||
<Field
|
||||
label={t('create.password')}
|
||||
hint={t('create.passwordHint')}
|
||||
error={form.formState.errors.password?.message}
|
||||
>
|
||||
<input type="password" autoComplete="new-password" {...form.register('password')} />
|
||||
</Field>
|
||||
<Field label={t('create.locale')}>
|
||||
<select {...form.register('locale')}>
|
||||
<option value="de">{t('create.localeDe')}</option>
|
||||
<option value="en">{t('create.localeEn')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<div className="modal__actions">
|
||||
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
||||
{t('create.submit')}
|
||||
</button>
|
||||
<button type="button" className="linklike" onClick={onClose}>
|
||||
{t('create.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
user,
|
||||
isSelf,
|
||||
|
||||
55
apps/web/src/pages/admin-settings-form.test.ts
Normal file
55
apps/web/src/pages/admin-settings-form.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
GENERAL_FORM_FIELDS,
|
||||
GeneralSettingsForm,
|
||||
toFormValues,
|
||||
toSettingsPatch,
|
||||
} from './admin-settings-form';
|
||||
|
||||
describe('admin general settings form model (issue #322)', () => {
|
||||
// The regression this file exists for: a dotted field name makes
|
||||
// react-hook-form nest the typed value and the strict PATCH schema
|
||||
// reject the body — the form then looks fine but never saves.
|
||||
it('uses no dots in any form field name', () => {
|
||||
for (const field of Object.keys(GENERAL_FORM_FIELDS)) {
|
||||
expect(field).not.toContain('.');
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips settings through form values back to a flat patch', () => {
|
||||
const settings = {
|
||||
'instance.name': 'My Wiki',
|
||||
'instance.defaultLocale': 'de',
|
||||
'auth.registrationMode': 'closed',
|
||||
'invitations.maxOpenPerUser': 5,
|
||||
'classification.newPageDefault': 'unclassified',
|
||||
'classification.uploadPolicy': 'warn',
|
||||
'quota.editorsPerPond': 5,
|
||||
'quota.readersPerPond': 50,
|
||||
'quota.additionalPonds': 0,
|
||||
'quota.storageBytes': 1024,
|
||||
'quota.maxFileBytes': 25,
|
||||
};
|
||||
expect(toSettingsPatch(toFormValues(settings))).toEqual(settings);
|
||||
});
|
||||
|
||||
it('patches only the settings this form edits, under their dotted keys', () => {
|
||||
const input: GeneralSettingsForm = {
|
||||
instanceName: 'Renamed',
|
||||
defaultLocale: 'en',
|
||||
registrationMode: 'open',
|
||||
invitationsMaxOpenPerUser: 5,
|
||||
newPageClassification: 'vs_nfd',
|
||||
uploadPolicy: 'block',
|
||||
quotaEditorsPerPond: 1,
|
||||
quotaReadersPerPond: 2,
|
||||
quotaAdditionalPonds: 3,
|
||||
quotaStorageBytes: 4,
|
||||
quotaMaxFileBytes: 5,
|
||||
};
|
||||
const patch = toSettingsPatch(input);
|
||||
expect(patch['instance.name']).toBe('Renamed');
|
||||
expect(Object.keys(patch).sort()).toEqual(Object.values(GENERAL_FORM_FIELDS).slice().sort());
|
||||
});
|
||||
});
|
||||
62
apps/web/src/pages/admin-settings-form.ts
Normal file
62
apps/web/src/pages/admin-settings-form.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Form model of the general + quota cards on the admin settings page.
|
||||
*
|
||||
* Field names MUST NOT contain dots: react-hook-form treats a dot in a
|
||||
* field name as a nested-path separator. A field registered under its
|
||||
* settings key ('instance.name') DISPLAYS fine — RHF's getter falls back
|
||||
* to the literal flat key — but typing writes the value into a nested
|
||||
* object ({ instance: { name } }), which the api's strict PATCH schema
|
||||
* rejects, so nothing ever saved (issue #322). This mapping is the single
|
||||
* place tying a dot-free field name to its dotted settings key; the
|
||||
* converters below translate in both directions.
|
||||
*/
|
||||
|
||||
export const GENERAL_FORM_FIELDS = {
|
||||
instanceName: 'instance.name',
|
||||
defaultLocale: 'instance.defaultLocale',
|
||||
registrationMode: 'auth.registrationMode',
|
||||
invitationsMaxOpenPerUser: 'invitations.maxOpenPerUser',
|
||||
newPageClassification: 'classification.newPageDefault',
|
||||
uploadPolicy: 'classification.uploadPolicy',
|
||||
quotaEditorsPerPond: 'quota.editorsPerPond',
|
||||
quotaReadersPerPond: 'quota.readersPerPond',
|
||||
quotaAdditionalPonds: 'quota.additionalPonds',
|
||||
quotaStorageBytes: 'quota.storageBytes',
|
||||
quotaMaxFileBytes: 'quota.maxFileBytes',
|
||||
} as const;
|
||||
|
||||
export type GeneralFormField = keyof typeof GENERAL_FORM_FIELDS;
|
||||
export type GeneralFormSettingKey = (typeof GENERAL_FORM_FIELDS)[GeneralFormField];
|
||||
|
||||
export interface GeneralSettingsForm {
|
||||
instanceName: string;
|
||||
defaultLocale: 'de' | 'en';
|
||||
registrationMode: 'open' | 'closed';
|
||||
invitationsMaxOpenPerUser: number;
|
||||
newPageClassification: 'unclassified' | 'vs_nfd';
|
||||
uploadPolicy: 'warn' | 'block';
|
||||
quotaEditorsPerPond: number;
|
||||
quotaReadersPerPond: number;
|
||||
quotaAdditionalPonds: number;
|
||||
quotaStorageBytes: number;
|
||||
quotaMaxFileBytes: number;
|
||||
}
|
||||
|
||||
/** The settings this form reads and writes, keyed by their dotted names. */
|
||||
export type GeneralFormSettings = Record<GeneralFormSettingKey, unknown>;
|
||||
|
||||
export function toFormValues(settings: GeneralFormSettings): GeneralSettingsForm {
|
||||
return Object.fromEntries(
|
||||
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [field, settings[key]]),
|
||||
) as unknown as GeneralSettingsForm;
|
||||
}
|
||||
|
||||
/** Flat dotted keys, exactly what PATCH /admin/settings expects. */
|
||||
export function toSettingsPatch(input: GeneralSettingsForm): GeneralFormSettings {
|
||||
return Object.fromEntries(
|
||||
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [
|
||||
key,
|
||||
input[field as GeneralFormField],
|
||||
]),
|
||||
) as GeneralFormSettings;
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { SignupFormInput, signupInputSchema } from '@dorfteich/shared';
|
||||
import { InvitationPreview, SignupFormInput, signupInputSchema } from '@dorfteich/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { Field, FormError, applyFieldErrors } from '../../components/forms';
|
||||
import { apiGet, apiPost } from '../../lib/api';
|
||||
@ -21,6 +21,19 @@ export function SignupPage(): React.JSX.Element {
|
||||
queryFn: () => apiGet<{ mode: 'open' | 'closed' }>('/auth/registration'),
|
||||
});
|
||||
|
||||
// An invitation link (issue #332) carries ?invitation=<token>: a valid
|
||||
// one lets this signup through even while registration is closed.
|
||||
const [searchParams] = useSearchParams();
|
||||
const invitationToken = searchParams.get('invitation');
|
||||
const invitation = useQuery({
|
||||
queryKey: ['invitation-preview', invitationToken],
|
||||
queryFn: () => apiPost<InvitationPreview>('/invitations/preview', { token: invitationToken }),
|
||||
enabled: Boolean(invitationToken),
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const invited = Boolean(invitationToken) && invitation.isSuccess;
|
||||
|
||||
type SignupFormValues = SignupFormInput;
|
||||
// One confined cast: RHF cannot express Zod's input/output split
|
||||
// (locale is optional on input, defaulted on output) without it.
|
||||
@ -29,10 +42,22 @@ export function SignupPage(): React.JSX.Element {
|
||||
defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' },
|
||||
});
|
||||
|
||||
// Prefill the invited address; it stays editable (the mailbox is
|
||||
// verified separately either way).
|
||||
const setValue = form.setValue;
|
||||
useEffect(() => {
|
||||
if (invitation.data) setValue('email', invitation.data.email);
|
||||
}, [invitation.data, setValue]);
|
||||
|
||||
const onSubmit = form.handleSubmit(async (input) => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost('/auth/signup', input);
|
||||
// Only a previewed-valid token rides along; a broken link falls
|
||||
// back to a plain signup instead of failing the whole form.
|
||||
await apiPost('/auth/signup', {
|
||||
...input,
|
||||
invitationToken: invited ? invitationToken : undefined,
|
||||
});
|
||||
setRegistered(input.email);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
@ -42,10 +67,25 @@ export function SignupPage(): React.JSX.Element {
|
||||
}
|
||||
});
|
||||
|
||||
if (registration.data?.mode === 'closed') {
|
||||
// With a pending invitation check, neither the closed screen nor the
|
||||
// form should flash — hold the decision until the preview settles.
|
||||
if (registration.data?.mode === 'closed' && invitationToken && invitation.isPending) {
|
||||
return (
|
||||
<div className="auth-card">
|
||||
<h1>{t('auth:signup.title')}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (registration.data?.mode === 'closed' && !invited) {
|
||||
return (
|
||||
<div className="auth-card">
|
||||
<h1>{t('auth:signup.title')}</h1>
|
||||
{invitationToken && invitation.isError && (
|
||||
<p className="form-banner form-banner--error signup-invitation__invalid">
|
||||
{t('invitations:signup.invalid')}
|
||||
</p>
|
||||
)}
|
||||
<p className="form-banner">{t('auth:signup.closed')}</p>
|
||||
<p className="auth-card__links">
|
||||
<Link to="/login">{t('auth:signup.loginLink')}</Link>
|
||||
@ -74,6 +114,19 @@ export function SignupPage(): React.JSX.Element {
|
||||
<div className="auth-card">
|
||||
<h1>{t('auth:signup.title')}</h1>
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
{invited && invitation.data && (
|
||||
<p className="form-banner signup-invitation__banner">
|
||||
{t('invitations:signup.banner', {
|
||||
inviterName: invitation.data.inviterName,
|
||||
email: invitation.data.email,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{invitationToken && invitation.isError && (
|
||||
<p className="form-banner form-banner--error signup-invitation__invalid">
|
||||
{t('invitations:signup.invalid')}
|
||||
</p>
|
||||
)}
|
||||
<FormError error={error} />
|
||||
<Field
|
||||
label={t('auth:signup.username')}
|
||||
|
||||
234
apps/web/src/ponds/PondBrandingSection.tsx
Normal file
234
apps/web/src/ponds/PondBrandingSection.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
import { LogoVariant, MAX_LOGO_EDGE, PondBranding } from '@dorfteich/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CropField } from '../branding/CropField';
|
||||
import { canvasToPngFile, drawCrop } from '../branding/crop';
|
||||
import { logoUrl } from '../branding/use-branding';
|
||||
import { FormError, FormSuccess } from '../components/forms';
|
||||
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
|
||||
|
||||
const FAVICON_SIZES = [32, 180] as const;
|
||||
|
||||
/**
|
||||
* A pond's own logo and favicon (issue #307).
|
||||
*
|
||||
* Reuses the instance screen's crop control rather than growing a second,
|
||||
* drag-only one: the uploader here is an ordinary Pond Admin, and the
|
||||
* keyboard operability is not theirs to lose.
|
||||
*/
|
||||
export function PondBrandingSection({ pondId }: { pondId: string }): React.JSX.Element {
|
||||
const { t } = useTranslation('branding');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const branding = useQuery({
|
||||
queryKey: ['pond', pondId, 'branding'],
|
||||
queryFn: () => apiGet<PondBranding>(`/ponds/${pondId}/branding`),
|
||||
});
|
||||
const view = branding.data;
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['pond', pondId, 'branding'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="branding pond-branding">
|
||||
<p>{t('pond.intro')}</p>
|
||||
<p>{t('pond.quotaNote')}</p>
|
||||
|
||||
<PondLogoSlot
|
||||
variant="light"
|
||||
pondId={pondId}
|
||||
asset={view?.logo ?? null}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
<PondLogoSlot
|
||||
variant="dark"
|
||||
pondId={pondId}
|
||||
asset={view?.logoDark ?? null}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
{view?.logo && !view.logoDark && (
|
||||
// Advisory, exactly as on the instance screen: a pond that uploads
|
||||
// only a light logo shows THAT logo in dark mode — it does not borrow
|
||||
// the instance's dark one. Nothing is blocked.
|
||||
<p className="branding__warning" role="note">
|
||||
<span aria-hidden="true">⚠ </span>
|
||||
{t('pond.darkMissing')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PondFaviconSlot pondId={pondId} present={Boolean(view?.favicon)} onChanged={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PondLogoSlot({
|
||||
pondId,
|
||||
variant,
|
||||
asset,
|
||||
onChanged,
|
||||
}: {
|
||||
pondId: string;
|
||||
variant: LogoVariant;
|
||||
asset: { hash: string; width: number; height: number } | null;
|
||||
onChanged: () => Promise<void>;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('branding');
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) throw new Error('no image');
|
||||
const form = new FormData();
|
||||
form.append('file', await canvasToPngFile(canvas, `logo-${variant}.png`));
|
||||
return apiPostForm<PondBranding>(`/ponds/${pondId}/branding/logo?variant=${variant}`, form);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setDone(true);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
const reset = useMutation({
|
||||
mutationFn: () => apiDelete(`/ponds/${pondId}/branding/logo?variant=${variant}`),
|
||||
onSuccess: async () => {
|
||||
setDone(false);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="branding__slot" data-pond-logo-variant={variant}>
|
||||
<h3>{t(`admin.logo.${variant}`)}</h3>
|
||||
<FormError error={upload.error ?? reset.error} />
|
||||
<FormSuccess message={done ? t('admin.logo.saved') : null} />
|
||||
{asset ? (
|
||||
<div className="branding__current">
|
||||
<img
|
||||
src={logoUrl(variant, asset.hash, pondId)}
|
||||
alt={t('admin.logo.currentAlt')}
|
||||
className={`branding__preview branding__preview--${variant}`}
|
||||
/>
|
||||
<p>{t('admin.logo.current', { width: asset.width, height: asset.height })}</p>
|
||||
{/* A real, labelled control — not an empty file field standing in
|
||||
for "remove". */}
|
||||
<button
|
||||
type="button"
|
||||
className="button button--outline"
|
||||
onClick={() => reset.mutate()}
|
||||
disabled={reset.isPending}
|
||||
>
|
||||
{t('pond.reset')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p>{t('pond.inherited')}</p>
|
||||
)}
|
||||
<CropField
|
||||
idPrefix={`pond-logo-${variant}`}
|
||||
square={false}
|
||||
maxEdge={MAX_LOGO_EDGE}
|
||||
onChange={(canvas) => {
|
||||
canvasRef.current = canvas;
|
||||
setDone(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => upload.mutate()}
|
||||
disabled={upload.isPending}
|
||||
>
|
||||
{t('admin.logo.submit')}
|
||||
</button>
|
||||
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PondFaviconSlot({
|
||||
pondId,
|
||||
present,
|
||||
onChanged,
|
||||
}: {
|
||||
pondId: string;
|
||||
present: boolean;
|
||||
onChanged: () => Promise<void>;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('branding');
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async () => {
|
||||
const source = canvasRef.current;
|
||||
if (!source) throw new Error('no image');
|
||||
const form = new FormData();
|
||||
for (const size of FAVICON_SIZES) {
|
||||
const scratch = document.createElement('canvas');
|
||||
drawCrop(
|
||||
source,
|
||||
{ x: 0, y: 0, width: source.width, height: source.height },
|
||||
{ width: size, height: size },
|
||||
scratch,
|
||||
);
|
||||
form.append(`png-${size}`, await canvasToPngFile(scratch, `favicon-${size}.png`));
|
||||
}
|
||||
return apiPostForm<PondBranding>(`/ponds/${pondId}/branding/favicon`, form);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setDone(true);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
const reset = useMutation({
|
||||
mutationFn: () => apiDelete(`/ponds/${pondId}/branding/favicon`),
|
||||
onSuccess: async () => {
|
||||
setDone(false);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="branding__slot" data-branding-slot="pond-favicon">
|
||||
<h3>{t('admin.favicon.title')}</h3>
|
||||
<p>{t('pond.faviconHint')}</p>
|
||||
<FormError error={upload.error ?? reset.error} />
|
||||
<FormSuccess message={done ? t('admin.favicon.saved') : null} />
|
||||
<p>{present ? t('admin.favicon.present') : t('pond.inherited')}</p>
|
||||
{present && (
|
||||
<button
|
||||
type="button"
|
||||
className="button button--outline"
|
||||
onClick={() => reset.mutate()}
|
||||
disabled={reset.isPending}
|
||||
>
|
||||
{t('pond.reset')}
|
||||
</button>
|
||||
)}
|
||||
<CropField
|
||||
idPrefix="pond-favicon"
|
||||
square
|
||||
maxEdge={180}
|
||||
onChange={(canvas) => {
|
||||
canvasRef.current = canvas;
|
||||
setDone(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => upload.mutate()}
|
||||
disabled={upload.isPending}
|
||||
>
|
||||
{t('admin.favicon.submit')}
|
||||
</button>
|
||||
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1732,6 +1732,42 @@ button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Gap cursor (issue #335): the blinking bar prosemirror-gapcursor renders at
|
||||
positions adjacent to block nodes without a text position of their own
|
||||
(e.g. a table as the page's only block). The upstream package does not
|
||||
ship its stylesheet through our import path, so these rules replace it. */
|
||||
.ProseMirror-gapcursor {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 20px;
|
||||
border-top: 1px solid var(--color-text);
|
||||
animation: dt-gapcursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes dt-gapcursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ProseMirror-gapcursor::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content h1,
|
||||
.editor-content h2,
|
||||
.editor-content h3,
|
||||
@ -3048,7 +3084,7 @@ ul[data-type='task_list'] li p:last-of-type {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.quota-row__override {
|
||||
.quota-row__override-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
@ -2,14 +2,17 @@
|
||||
# next to docker-compose.yml and adjust the values.
|
||||
|
||||
# --- required ---------------------------------------------------------------
|
||||
# PostgreSQL password for the `dorfteich` database user.
|
||||
# PostgreSQL password for the `dorfteich` database user. URL-SAFE
|
||||
# characters only (generate with `openssl rand -hex 24`): the compose file
|
||||
# interpolates it into DATABASE_URL unescaped, so base64's `/`, `+`, `=`
|
||||
# break the URL — db stays healthy while api/collab/backup restart-loop.
|
||||
POSTGRES_PASSWORD=change-me
|
||||
|
||||
# ROOT key of the token key hierarchy (ADR 0020, issue #188): every token
|
||||
# purpose (collaboration tokens, digest unsubscribe links) derives its own
|
||||
# HKDF subkey from this value — nothing signs with it directly. The api and
|
||||
# collab services share this one value; use a long random string
|
||||
# (e.g. `openssl rand -base64 32`). Min length 16. Rotating it rotates all
|
||||
# (e.g. `openssl rand -hex 24`). Min length 16. Rotating it rotates all
|
||||
# derived keys at once and invalidates outstanding tokens.
|
||||
COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string
|
||||
|
||||
@ -52,7 +55,7 @@ COMPOSE_PROJECT_NAME=dorfteich
|
||||
# --- public URL + mail --------------------------------------------------------
|
||||
# Public base URL of the stage (scheme + host). E-mail links and the CSRF
|
||||
# origin check are derived from it — it must match what browsers use.
|
||||
APP_BASE_URL=https://test.dorfteich.cloud
|
||||
APP_BASE_URL=https://wiki.example.com
|
||||
|
||||
# SMTP relay for outgoing mail (verification, password reset). Optional:
|
||||
# leave everything unset and configure the relay in the browser during the
|
||||
@ -76,10 +79,36 @@ SMTP_FROM=Dorfteich <wiki@example.com>
|
||||
#CADDY_HTTP_PORT=80
|
||||
#CADDY_HTTPS_PORT=443
|
||||
|
||||
# --- external authentication (issues #214–#216, ADR 0021) ---------------------
|
||||
# Deploy-level on purpose — a Site Admin cannot change these. All unset =
|
||||
# local username/password login only. Full reference:
|
||||
# docs/architecture/security.md §External authentication.
|
||||
# OIDC (Authorization Code + PKCE) is enabled iff ISSUER + CLIENT_ID are set;
|
||||
# CLIENT_SECRET stays empty for a public client. Redirect URI to register at
|
||||
# the IdP: $APP_BASE_URL/api/v1/auth/oidc/callback
|
||||
#OIDC_ISSUER=https://idp.example.com/realms/example
|
||||
#OIDC_CLIENT_ID=dorfteich-web
|
||||
#OIDC_CLIENT_SECRET=
|
||||
#OIDC_SCOPES=openid profile email
|
||||
#OIDC_PROVIDER_LABEL=Single Sign-On
|
||||
# Hard switch (#216): false turns EVERY local credential flow off (404) —
|
||||
# sign-in only via OIDC or the trusted proxy. Complete the first-run setup
|
||||
# BEFORE flipping it.
|
||||
#AUTH_LOCAL_ENABLED=false
|
||||
# Perimeter authentication (#215): identity from a proxy header, honoured
|
||||
# only when the TCP peer is on the allowlist; the proxy MUST strip the
|
||||
# header from incoming traffic. Unset = feature off, the header is inert.
|
||||
#AUTH_PROXY_HEADER=X-Auth-User
|
||||
#AUTH_PROXY_TRUSTED_PEERS=10.0.0.5
|
||||
#AUTH_PROXY_MAP=username
|
||||
#AUTH_PROXY_MODE=plain
|
||||
#AUTH_PROXY_DN_ATTRIBUTE=CN
|
||||
|
||||
# --- backups (ADR 0015, issue #83) --------------------------------------------
|
||||
# The backup sidecar dumps the database and archives the uploads/plugins
|
||||
# volumes nightly onto the `backups` volume; restore via
|
||||
# deploy/backup/restore.sh <backup-id>. All values optional.
|
||||
# The backup sidecar dumps the database and archives the data volumes
|
||||
# (uploads, plugins, custom fonts, branding) nightly onto the `backups`
|
||||
# volume; restore via deploy/backup/restore.sh <backup-id>. All values
|
||||
# optional.
|
||||
# Daily run time HH:MM in TZ (default 03:00; set TZ for stage-local time,
|
||||
# e.g. TZ=Europe/Berlin — unset means UTC).
|
||||
#TZ=Europe/Berlin
|
||||
@ -129,6 +158,9 @@ SMTP_FROM=Dorfteich <wiki@example.com>
|
||||
# wizard. Automated deploys can skip it entirely by pre-seeding the Site
|
||||
# Admin here; the wizard then completes and locks itself at first boot.
|
||||
# All three SETUP_ADMIN_* values are required for pre-seeding to trigger.
|
||||
# The wizard's validation applies: the password needs at least 10
|
||||
# characters — a violation fails the boot with a message naming the
|
||||
# variable (deliberate: no half-seeded instance).
|
||||
#SETUP_ADMIN_USERNAME=admin
|
||||
#SETUP_ADMIN_EMAIL=admin@example.com
|
||||
#SETUP_ADMIN_PASSWORD=change-me-please
|
||||
|
||||
@ -61,6 +61,21 @@ services:
|
||||
# VS-NfD hardening-profile mode (issue #243, ADR 0027):
|
||||
# off | marked | hidden | enforced. Empty = off — no marking anywhere.
|
||||
VS_NFD_MODE: ${VS_NFD_MODE:-}
|
||||
# External authentication (issues #214–#216, ADR 0021) — deploy-level
|
||||
# on purpose, out of a Site Admin's reach. All empty = local
|
||||
# username/password login only. Reference: security.md §External
|
||||
# authentication.
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
OIDC_SCOPES: ${OIDC_SCOPES:-}
|
||||
OIDC_PROVIDER_LABEL: ${OIDC_PROVIDER_LABEL:-}
|
||||
AUTH_LOCAL_ENABLED: ${AUTH_LOCAL_ENABLED:-}
|
||||
AUTH_PROXY_HEADER: ${AUTH_PROXY_HEADER:-}
|
||||
AUTH_PROXY_TRUSTED_PEERS: ${AUTH_PROXY_TRUSTED_PEERS:-}
|
||||
AUTH_PROXY_MAP: ${AUTH_PROXY_MAP:-}
|
||||
AUTH_PROXY_MODE: ${AUTH_PROXY_MODE:-}
|
||||
AUTH_PROXY_DN_ATTRIBUTE: ${AUTH_PROXY_DN_ATTRIBUTE:-}
|
||||
# SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes
|
||||
# the relay to the secret store on the `secrets` volume (issue #80);
|
||||
# values set here in the stage .env always win over the store.
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
# Audit event catalogue
|
||||
|
||||
**Catalogue version 1.8 (2026-08-01; 1.8 adds `pond.archived`,
|
||||
**Catalogue version 1.10 (2026-08-05; 1.10 adds `invitation.created`,
|
||||
`invitation.revoked` and `invitation.accepted`, issue #332;
|
||||
1.9 added `user.created_by_admin`,
|
||||
issue #331; 1.8 added `pond.archived`,
|
||||
issue #305; 1.7 added `branding.changed`,
|
||||
issue #306; 1.6 added `font.uploaded` and
|
||||
`font.deleted`, issue #303; 1.5 added `plugin.rejected`,
|
||||
@ -77,6 +80,14 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
||||
| `auth.identity_linked` | OIDC identity linked to an existing account via the explicit link flow (issue #214) | notice | the linking user | — | `provider` |
|
||||
| `auth.proxy_rejected` | Proxy-auth header received from a peer outside the allowlist — spoof attempt (issue #215) | warning | `null` (unauthenticated) | — | `peer`, `header` |
|
||||
|
||||
### Invitations (`invitation.*`, issue #332)
|
||||
|
||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||
| --------------------- | ---------------------------------------------------------------- | -------- | ----------------- | ------------ | ------ |
|
||||
| `invitation.created` | User invites an e-mail address (mail with signup link sent) | info | the inviting user | `invitation` | — |
|
||||
| `invitation.revoked` | Open invitation withdrawn by its creator | info | the inviting user | `invitation` | — |
|
||||
| `invitation.accepted` | Signup completed through an invitation link (closed-mode bypass) | notice | the new user | `invitation` | — |
|
||||
|
||||
### Access & membership (`grant.*`, `member.*`)
|
||||
|
||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||
@ -91,6 +102,7 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
||||
|
||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||
| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `user.created_by_admin` | Site Admin creates an account directly (#331) | notice | the admin | `user` | — |
|
||||
| `user.disabled_set` | Site Admin disables/enables an account | notice | the admin | `user` | `disabled` (bool) |
|
||||
| `user.site_admin_set` | Site-Admin privilege granted/revoked | notice | the admin | `user` | `isSiteAdmin` (bool) |
|
||||
| `user.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — |
|
||||
|
||||
@ -19,22 +19,31 @@ work, that is a bug (issue #88).
|
||||
|
||||
## Install
|
||||
|
||||
1. Create a directory and fetch the two reference files from the repository
|
||||
1. Create a directory and fetch the reference files from the repository
|
||||
(`deploy/compose/`): `docker-compose.yml`, `.env.example` — plus
|
||||
`Caddyfile` if you want the `caddy` profile.
|
||||
|
||||
```sh
|
||||
mkdir dorfteich && cd dorfteich
|
||||
# copy docker-compose.yml, .env.example (and Caddyfile) here
|
||||
base=https://gitea.101010.cloud/stwaidele/dorfteich/raw/branch/main/deploy/compose
|
||||
curl -fsSLO "$base/docker-compose.yml"
|
||||
curl -fsSLO "$base/.env.example"
|
||||
curl -fsSLO "$base/Caddyfile" # only for the caddy profile
|
||||
cp .env.example .env && chmod 600 .env
|
||||
```
|
||||
|
||||
2. Edit `.env` — the minimum:
|
||||
- `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings
|
||||
(`openssl rand -base64 32`).
|
||||
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`:
|
||||
releases are semver tags (`v1.2.3`); until the first public release,
|
||||
`test` tracks the latest verified build.
|
||||
- `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings —
|
||||
generate both with `openssl rand -hex 24`. Stick to URL-safe
|
||||
characters for the database password (hex is): it is interpolated
|
||||
into a connection URL, and a `/`, `+` or `=` from base64 output
|
||||
breaks it in a confusing way (db healthy, everything else
|
||||
restart-looping — see Troubleshooting).
|
||||
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`: pin
|
||||
the latest release tag (semver, e.g. `v0.14.0`) — the
|
||||
[release list](https://gitea.101010.cloud/stwaidele/dorfteich/releases)
|
||||
is authoritative. Moving tags like `test`/`int` track our stages and
|
||||
are not meant for third-party installs.
|
||||
- `APP_BASE_URL=https://wiki.example.com` — must be exactly what
|
||||
browsers will use; e-mail links and the CSRF origin check derive
|
||||
from it.
|
||||
@ -76,12 +85,18 @@ and health endpoints with `503 setup_required` — that is not an error.
|
||||
|
||||
Unattended installs skip the wizard by pre-seeding: set the
|
||||
`SETUP_ADMIN_*` variables in `.env` before the first start (see
|
||||
`.env.example`).
|
||||
`.env.example`). The same validation as in the wizard applies —
|
||||
`SETUP_ADMIN_PASSWORD` needs **at least 10 characters** — and an invalid
|
||||
value deliberately fails the boot with a message naming the variable
|
||||
(a half-seeded instance would be harder to diagnose).
|
||||
|
||||
## Updating
|
||||
|
||||
```sh
|
||||
# edit .env: TAG=v1.3.0
|
||||
# 1. take a backup first — the pre-update set is the guaranteed way back:
|
||||
# Admin → System → "Back up now", or:
|
||||
docker compose run --rm -e BACKUP_RUN_ONCE=1 backup
|
||||
# 2. edit .env: TAG=v1.3.0
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
@ -89,14 +104,17 @@ Database migrations run automatically at api start. Release notes flag
|
||||
releases with a `migration` label and any manual steps. **Downgrade
|
||||
window: one minor release** — `TAG` back + `pull` + `up -d` is supported
|
||||
one step back; further back, restore the backup taken before the update
|
||||
instead (the nightly sidecar gives you one at most 24 h old).
|
||||
instead. Rollback paths — including what to do when a migration itself
|
||||
fails — are in
|
||||
[`docs/operations/update-runbook.md`](../operations/update-runbook.md).
|
||||
|
||||
## Backups & restore
|
||||
|
||||
Enabled by default (ADR 0015): the `backup` sidecar dumps the database and
|
||||
archives the uploads/plugins volumes nightly at `BACKUP_TIME` onto the
|
||||
`backups` volume, prunes by `BACKUP_RETENTION_DAYS`, writes `status.json`,
|
||||
and — with `BACKUP_MAIL_TO` set — mails you on failure.
|
||||
archives the data volumes (uploads, plugins, custom fonts, branding)
|
||||
nightly at `BACKUP_TIME` onto the `backups` volume, prunes by
|
||||
`BACKUP_RETENTION_DAYS`, writes `status.json`, and — with
|
||||
`BACKUP_MAIL_TO` set — mails you on failure.
|
||||
|
||||
- On-demand backup: `docker compose run --rm -e BACKUP_RUN_ONCE=1 backup`,
|
||||
or the **Back up now** button under _Admin → System_.
|
||||
@ -138,6 +156,19 @@ If the app itself is gone, use the operator path in
|
||||
`docs/operations/restore-runbook.md` instead — it documents fetching a
|
||||
bundle from Nextcloud by hand.
|
||||
|
||||
## External authentication (optional)
|
||||
|
||||
Local username/password accounts work out of the box — nothing to
|
||||
configure. Deployments with an existing identity provider can add OpenID
|
||||
Connect login (`OIDC_ISSUER` + `OIDC_CLIENT_ID` in `.env` enable it), let
|
||||
an authenticating reverse proxy assert identities (`AUTH_PROXY_*`), and —
|
||||
after the first-run setup is complete — turn local credentials off
|
||||
entirely with `AUTH_LOCAL_ENABLED=false`. All of this is deploy-level by
|
||||
design: a Site Admin cannot change it from the UI. The variables are
|
||||
documented in `.env.example`; semantics and the trust model are in
|
||||
[`docs/architecture/security.md`](../architecture/security.md)
|
||||
(§External authentication).
|
||||
|
||||
## Public REST API
|
||||
|
||||
Scripts and integrations can talk to the instance through a
|
||||
@ -161,6 +192,15 @@ and the OpenAPI document: [public-api.md](public-api.md).
|
||||
mutations fail with 403 `csrf_origin_mismatch` → `APP_BASE_URL` does not
|
||||
match the URL in the browser (scheme and host must be identical).
|
||||
E-mail links point at the wrong host → same variable.
|
||||
- api, collab **and** backup restart-looping while `db` is healthy →
|
||||
`POSTGRES_PASSWORD` contains characters that break the connection URL
|
||||
(base64's `/`, `+`, `=`); regenerate with `openssl rand -hex 24` and
|
||||
recreate the stack. The db container looks fine because only its
|
||||
clients build a URL from the password.
|
||||
- api restart-looping right after the first start with a
|
||||
`Pre-seeding failed` (or `validation.password.tooShort`) message →
|
||||
`SETUP_ADMIN_PASSWORD` is shorter than 10 characters; fix `.env` and
|
||||
recreate the api container.
|
||||
- Wizard reappears after a restart → the database volume was not
|
||||
persisted; never run without the `db-data` volume.
|
||||
- `docker compose ps` shows `unhealthy` → that container's liveness check
|
||||
|
||||
@ -29,6 +29,7 @@ Settings-Cache ist in-process (operations.md).
|
||||
| Setting | Referenzwert | Default | Warum |
|
||||
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. |
|
||||
| `invitations.maxOpenPerUser` | `0` | `5` | **explizit setzen** — Einladungen (#332) öffnen einen kontrollierten Registrierungsweg an `auth.registrationMode=closed` vorbei; in der Referenzkonfiguration bleibt der Nutzerkreis allein Sache des Betreibers (Konten legt der Site-Admin an, #331). `0` schaltet das Einladen ab (403 `invitations_disabled`). |
|
||||
| `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). |
|
||||
| `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. |
|
||||
| `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. |
|
||||
|
||||
@ -20,6 +20,8 @@ import { fileURLToPath } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
import { thirdPartyNotices } from '../third-party-licenses.mjs';
|
||||
|
||||
const DRAWIO_VERSION = '30.3.6';
|
||||
const DRAWIO_TARBALL = `https://github.com/jgraph/drawio/archive/refs/tags/v${DRAWIO_VERSION}.tar.gz`;
|
||||
|
||||
@ -27,12 +29,16 @@ const root = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
|
||||
const vendor = join(root, 'vendor');
|
||||
const webapp = join(vendor, `drawio-${DRAWIO_VERSION}`, 'src', 'main', 'webapp');
|
||||
// Apache-2.0 requires a copy of the license with any redistribution (§4(a)),
|
||||
// so the tarball's root LICENSE ships in the ZIP (issue #345).
|
||||
const licenseFile = join(vendor, `drawio-${DRAWIO_VERSION}`, 'LICENSE');
|
||||
|
||||
// --- 1. Fetch + unpack the pinned draw.io release (cached in vendor/) -----
|
||||
// In CI the vendor fetch is skipped (network + 60 MB — the fonts-build
|
||||
// lesson): the controller bundle still builds, only the installable ZIP
|
||||
// needs a dev machine (or a pre-populated vendor/ cache).
|
||||
if (!existsSync(webapp)) {
|
||||
// needs a dev machine (or a pre-populated vendor/ cache). The LICENSE guard
|
||||
// also heals vendor/ caches unpacked before #345 added it.
|
||||
if (!existsSync(webapp) || !existsSync(licenseFile)) {
|
||||
if (process.env.CI) {
|
||||
console.log('CI: skipping draw.io vendor fetch — bundling plugin.js only, no ZIP');
|
||||
await bundleController();
|
||||
@ -44,9 +50,18 @@ if (!existsSync(webapp)) {
|
||||
console.log(`fetching draw.io v${DRAWIO_VERSION} …`);
|
||||
execFileSync('curl', ['-sfL', '-o', tarball, DRAWIO_TARBALL], { stdio: 'inherit' });
|
||||
}
|
||||
execFileSync('tar', ['-xzf', tarball, '-C', vendor, `drawio-${DRAWIO_VERSION}/src/main/webapp`], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
execFileSync(
|
||||
'tar',
|
||||
[
|
||||
'-xzf',
|
||||
tarball,
|
||||
'-C',
|
||||
vendor,
|
||||
`drawio-${DRAWIO_VERSION}/src/main/webapp`,
|
||||
`drawio-${DRAWIO_VERSION}/LICENSE`,
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
}
|
||||
|
||||
// --- 2. Select the runtime subset -----------------------------------------
|
||||
@ -103,20 +118,32 @@ const drawioFiles = collect(webapp, '');
|
||||
// --- 3. Bundle the plugin controller ---------------------------------------
|
||||
async function bundleController() {
|
||||
mkdirSync(join(root, 'dist'), { recursive: true });
|
||||
await build({
|
||||
const result = await build({
|
||||
entryPoints: [join(root, 'src/plugin.ts')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
outfile: join(root, 'dist/plugin.js'),
|
||||
minify: true,
|
||||
metafile: true,
|
||||
});
|
||||
return result.metafile;
|
||||
}
|
||||
await bundleController();
|
||||
const metafile = await bundleController();
|
||||
|
||||
// --- 4. Pack the ZIP --------------------------------------------------------
|
||||
const files = {
|
||||
'manifest.json': readFileSync(join(root, 'manifest.json')),
|
||||
'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
|
||||
'licenses/drawio-LICENSE.txt': readFileSync(licenseFile),
|
||||
'licenses/THIRD-PARTY-NOTICES.txt': Buffer.from(
|
||||
thirdPartyNotices(metafile, [
|
||||
{
|
||||
title: `draw.io ${DRAWIO_VERSION} (bundled webapp under assets/drawio/)`,
|
||||
license: 'Apache-2.0',
|
||||
note: `Source: ${DRAWIO_TARBALL} — full license text in licenses/drawio-LICENSE.txt.`,
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
for (const name of readdirSync(join(root, 'i18n'))) {
|
||||
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));
|
||||
|
||||
@ -20,6 +20,8 @@ import { fileURLToPath } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
import { thirdPartyNotices } from '../third-party-licenses.mjs';
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
|
||||
const require = createRequire(import.meta.url);
|
||||
@ -73,7 +75,7 @@ for (const sub of ASSET_SUBDIRS) {
|
||||
|
||||
// --- 2. Bundle the plugin controller (React + Excalidraw) ------------------
|
||||
mkdirSync(join(root, 'dist'), { recursive: true });
|
||||
await build({
|
||||
const buildResult = await build({
|
||||
entryPoints: [join(root, 'src/plugin.tsx')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
@ -89,6 +91,7 @@ await build({
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
'process.env.IS_PREACT': '"false"',
|
||||
},
|
||||
metafile: true,
|
||||
});
|
||||
|
||||
// --- 3. Pack the ZIP --------------------------------------------------------
|
||||
@ -103,6 +106,29 @@ for (const name of readdirSync(join(root, 'i18n'))) {
|
||||
// and sit at the ZIP root so they resolve under EXCALIDRAW_ASSET_PATH.
|
||||
Object.assign(files, assetFiles);
|
||||
|
||||
// License texts for the redistributed material (issue #345): bundled npm
|
||||
// packages come from the metafile; the shipped fonts have no license files
|
||||
// upstream at all, so the texts are curated in licenses/ (see FONT-NOTICES.md)
|
||||
// — @excalidraw/excalidraw ships no LICENSE file either, hence the committed
|
||||
// MIT text instead of the metafile fallback line.
|
||||
for (const name of readdirSync(join(root, 'licenses'))) {
|
||||
files[`licenses/${name}`] = readFileSync(join(root, 'licenses', name));
|
||||
}
|
||||
files['licenses/THIRD-PARTY-NOTICES.txt'] = Buffer.from(
|
||||
thirdPartyNotices(buildResult.metafile, [
|
||||
{
|
||||
title: 'Excalidraw (bundled into plugin.js)',
|
||||
license: 'MIT',
|
||||
note: 'Full license text in licenses/excalidraw-MIT.txt.',
|
||||
},
|
||||
{
|
||||
title: 'Fonts (shipped under fonts/)',
|
||||
license: 'OFL-1.1 and MIT, per family',
|
||||
note: 'Attribution table in licenses/FONT-NOTICES.md; per-font license texts alongside it.',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`);
|
||||
rmSync(target, { force: true });
|
||||
writeFileSync(target, zipSync(files, { level: 6 }));
|
||||
|
||||
95
packages/plugins/excalidraw/licenses/Assistant-OFL.txt
Normal file
95
packages/plugins/excalidraw/licenses/Assistant-OFL.txt
Normal file
@ -0,0 +1,95 @@
|
||||
Copyright 2020 The Assistant Project Authors (https://github.com/hafontia/Assistant).
|
||||
Copyright 2010 The Source Sans Pro Authors (https://github.com/adobe-fonts/source-sans-pro), with Reserved Font Name 'Source'.
|
||||
Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
94
packages/plugins/excalidraw/licenses/CascadiaCode-OFL.txt
Normal file
94
packages/plugins/excalidraw/licenses/CascadiaCode-OFL.txt
Normal file
@ -0,0 +1,94 @@
|
||||
Copyright (c) 2019 - Present, Microsoft Corporation,
|
||||
with Reserved Font Name Cascadia Code.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
21
packages/plugins/excalidraw/licenses/ComicShanns-MIT.txt
Normal file
21
packages/plugins/excalidraw/licenses/ComicShanns-MIT.txt
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Shannon Miwa
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
22
packages/plugins/excalidraw/licenses/FONT-NOTICES.md
Normal file
22
packages/plugins/excalidraw/licenses/FONT-NOTICES.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Font notices
|
||||
|
||||
The Excalidraw plugin package ships the font files that Excalidraw's
|
||||
prod build loads at runtime (`fonts/`). Neither the npm package nor the
|
||||
Excalidraw repository ships license files next to the fonts, so the
|
||||
attributions are collected here (issue #345); each referenced text in
|
||||
this directory carries the font's own copyright statement.
|
||||
|
||||
| Font family | License | Text | Upstream |
|
||||
| --------------- | ------- | ---------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Assistant | OFL-1.1 | `Assistant-OFL.txt` | https://github.com/hafontia/Assistant |
|
||||
| Cascadia Code | OFL-1.1 | `CascadiaCode-OFL.txt` | https://github.com/microsoft/cascadia-code |
|
||||
| Comic Shanns | MIT | `ComicShanns-MIT.txt` | https://github.com/shannpersand/comic-shanns |
|
||||
| Excalifont | MIT | `excalidraw-MIT.txt` | Published as part of https://github.com/excalidraw/excalidraw (no separate font license upstream) |
|
||||
| Liberation Sans | OFL-1.1 | `Liberation-OFL.txt` | https://github.com/liberationfonts/liberation-fonts |
|
||||
| Lilita One | OFL-1.1 | `LilitaOne-OFL.txt` | https://fonts.google.com/specimen/Lilita+One |
|
||||
| Nunito | OFL-1.1 | `Nunito-OFL.txt` | https://github.com/googlefonts/nunito |
|
||||
| Virgil | OFL-1.1 | `Virgil-OFL.txt` | https://github.com/excalidraw/virgil |
|
||||
| Xiaolai | OFL-1.1 | `Xiaolai-OFL.txt` | https://github.com/lxgw/kose-font |
|
||||
|
||||
The SIL Open Font License permits use, redistribution, and bundling
|
||||
with software; it applies to the font files, not to this plugin's code.
|
||||
102
packages/plugins/excalidraw/licenses/Liberation-OFL.txt
Normal file
102
packages/plugins/excalidraw/licenses/Liberation-OFL.txt
Normal file
@ -0,0 +1,102 @@
|
||||
Digitized data copyright (c) 2010 Google Corporation
|
||||
with Reserved Font Arimo, Tinos and Cousine.
|
||||
Copyright (c) 2012 Red Hat, Inc.
|
||||
with Reserved Font Name Liberation.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
|
||||
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
|
||||
worldwide development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to provide
|
||||
a free and open framework in which fonts may be shared and improved in
|
||||
partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves.
|
||||
The fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such.
|
||||
This may include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting ? in part or in whole ?
|
||||
any of the components of the Original Version, by changing formats or
|
||||
by porting the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical writer
|
||||
or other person who contributed to the Font Software.
|
||||
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this license, and must not be distributed
|
||||
under any other license. The requirement for fonts to remain under
|
||||
this license does not apply to any document created using the Font
|
||||
Software.
|
||||
|
||||
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
94
packages/plugins/excalidraw/licenses/LilitaOne-OFL.txt
Normal file
94
packages/plugins/excalidraw/licenses/LilitaOne-OFL.txt
Normal file
@ -0,0 +1,94 @@
|
||||
Copyright (c) 2011 Juan Montoreano (juan@remolacha.biz),
|
||||
with Reserved Font Name Lilita
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
packages/plugins/excalidraw/licenses/Nunito-OFL.txt
Normal file
93
packages/plugins/excalidraw/licenses/Nunito-OFL.txt
Normal file
@ -0,0 +1,93 @@
|
||||
Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
45
packages/plugins/excalidraw/licenses/Virgil-OFL.txt
Normal file
45
packages/plugins/excalidraw/licenses/Virgil-OFL.txt
Normal file
@ -0,0 +1,45 @@
|
||||
Copyright (c) 2021 - Present, Ellinor Rapp, with Reserved Font Name Virgil.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: [scripts.sil.org/OFL](https://scripts.sil.org/OFL).
|
||||
|
||||
# SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
|
||||
## PREAMBLE
|
||||
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
|
||||
## DEFINITIONS
|
||||
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
|
||||
## PERMISSION & CONDITIONS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
|
||||
1. Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2. Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3. No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
|
||||
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
|
||||
5. The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
|
||||
## TERMINATION
|
||||
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
## DISCLAIMER
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
94
packages/plugins/excalidraw/licenses/Xiaolai-OFL.txt
Normal file
94
packages/plugins/excalidraw/licenses/Xiaolai-OFL.txt
Normal file
@ -0,0 +1,94 @@
|
||||
Copyright 2020-2024 LXGW (https://github.com/lxgw/kose-font)
|
||||
Copyright 2014 Nozomi Seto (https://ja.osdn.net/projects/setofont/)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
21
packages/plugins/excalidraw/licenses/excalidraw-MIT.txt
Normal file
21
packages/plugins/excalidraw/licenses/excalidraw-MIT.txt
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Excalidraw
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@ -9,21 +9,27 @@ import { fileURLToPath } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
import { thirdPartyNotices } from '../third-party-licenses.mjs';
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
|
||||
|
||||
mkdirSync(join(root, 'dist'), { recursive: true });
|
||||
await build({
|
||||
const buildResult = await build({
|
||||
entryPoints: [join(root, 'src/plugin.ts')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
outfile: join(root, 'dist/plugin.js'),
|
||||
minify: true,
|
||||
metafile: true,
|
||||
});
|
||||
|
||||
const files = {
|
||||
'manifest.json': readFileSync(join(root, 'manifest.json')),
|
||||
'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
|
||||
// mermaid and its transitive dependencies are bundled into plugin.js; their
|
||||
// license texts ship with the package they belong to (issue #345).
|
||||
'licenses/THIRD-PARTY-NOTICES.txt': Buffer.from(thirdPartyNotices(buildResult.metafile)),
|
||||
};
|
||||
for (const name of readdirSync(join(root, 'i18n'))) {
|
||||
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));
|
||||
|
||||
82
packages/plugins/third-party-licenses.mjs
Normal file
82
packages/plugins/third-party-licenses.mjs
Normal file
@ -0,0 +1,82 @@
|
||||
// Third-party license notices for plugin ZIPs (issue #345). A plugin that
|
||||
// redistributes third-party material must ship the license texts alongside it
|
||||
// (Apache-2.0 §4(a), MIT's notice clause, OFL §2). The bundled-package list is
|
||||
// derived from the esbuild metafile — the set of files that actually ended up
|
||||
// in plugin.js — so the notices can never drift from the bundle the way a
|
||||
// hand-maintained list would. Non-bundled material (vendored webapps, copied
|
||||
// font assets) cannot appear in a metafile; callers pass those as `extras`.
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve, sep } from 'node:path';
|
||||
|
||||
const LICENSE_FILE_PATTERN = /^(licen[cs]e|copying|notice)(\.|$)/i;
|
||||
|
||||
/** Walk up from `file` to the nearest package.json that names a package. */
|
||||
function packageRootOf(file) {
|
||||
let dir = dirname(resolve(file));
|
||||
while (dir !== dirname(dir)) {
|
||||
const pj = join(dir, 'package.json');
|
||||
if (existsSync(pj)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(pj, 'utf8'));
|
||||
if (parsed.name) return { dir, pkg: parsed };
|
||||
} catch {
|
||||
// unreadable package.json (e.g. a fixture) — keep walking up
|
||||
}
|
||||
}
|
||||
dir = dirname(dir);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shippedLicenseText(dir) {
|
||||
const names = readdirSync(dir).filter((name) => LICENSE_FILE_PATTERN.test(name));
|
||||
return names
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(dir, name), 'utf8').trim())
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* All third-party npm packages whose files the metafile lists as bundle
|
||||
* inputs, deduplicated by name@version. First-party `@dorfteich/*` packages
|
||||
* are covered by the repository LICENSE and skipped.
|
||||
*/
|
||||
export function bundledPackages(metafile) {
|
||||
const seen = new Map();
|
||||
for (const input of Object.keys(metafile.inputs)) {
|
||||
if (!input.split(sep).includes('node_modules') && !input.includes('/node_modules/')) continue;
|
||||
const found = packageRootOf(input);
|
||||
if (!found || found.pkg.name.startsWith('@dorfteich/')) continue;
|
||||
const key = `${found.pkg.name}@${found.pkg.version}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, {
|
||||
name: found.pkg.name,
|
||||
version: found.pkg.version,
|
||||
license: typeof found.pkg.license === 'string' ? found.pkg.license : 'see license text',
|
||||
text: shippedLicenseText(found.dir),
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `licenses/THIRD-PARTY-NOTICES.txt` for a plugin ZIP: one section per
|
||||
* bundled package (license expression + the license file it ships), then one
|
||||
* per caller-supplied extra ({ title, license, note?, text? }).
|
||||
*/
|
||||
export function thirdPartyNotices(metafile, extras = []) {
|
||||
const rule = '='.repeat(72);
|
||||
const sections = [
|
||||
'THIRD-PARTY NOTICES\n\nThis plugin package redistributes the third-party components listed\nbelow, each under its own license.\n',
|
||||
];
|
||||
for (const pkg of bundledPackages(metafile)) {
|
||||
const body = pkg.text || `License: ${pkg.license} (no license file shipped in the npm package)`;
|
||||
sections.push(`${rule}\n${pkg.name} ${pkg.version} — ${pkg.license}\n${rule}\n\n${body}\n`);
|
||||
}
|
||||
for (const extra of extras) {
|
||||
const parts = [extra.note, extra.text].filter(Boolean).join('\n\n');
|
||||
sections.push(`${rule}\n${extra.title} — ${extra.license}\n${rule}\n\n${parts}\n`);
|
||||
}
|
||||
return sections.join('\n');
|
||||
}
|
||||
@ -37,5 +37,13 @@
|
||||
"size": "Kantenlänge des Ausschnitts (px)",
|
||||
"reset": "Ausschnitt zurücksetzen",
|
||||
"result": "Ergebnis: {{width}} × {{height}} px (Ausgangsbild {{sourceWidth}} × {{sourceHeight}} px)."
|
||||
},
|
||||
"pond": {
|
||||
"intro": "Dieser Teich kann ein eigenes Logo und ein eigenes Favicon führen. Beides überschreibt das der Instanz — nur für diesen Teich.",
|
||||
"quotaNote": "Die Dateien zählen auf das Speicher-Kontingent dieses Teichs, wie Anhänge auch.",
|
||||
"inherited": "Nichts hinterlegt — es gilt die Einstellung der Instanz.",
|
||||
"reset": "Auf die Instanz-Einstellung zurücksetzen",
|
||||
"darkMissing": "Für den Dunkelmodus ist kein eigenes Teich-Logo hinterlegt. Dann wird dort das helle Logo dieses Teichs verwendet — NICHT das dunkle Logo der Instanz. Ein Logo-Satz gehört zu einer Ebene und wird nie ebenenübergreifend gemischt. Das ist ein Hinweis, keine Sperre.",
|
||||
"faviconHint": "Das Favicon wird beim Betreten des Teichs im Tab gesetzt und beim Verlassen wieder zurückgestellt. Beim direkten Öffnen eines Teich-Links erscheint kurz das Instanz-Favicon, bevor es wechselt."
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,6 +76,8 @@
|
||||
"addRowBefore": "Zeile davor einfügen",
|
||||
"addRowAfter": "Zeile danach einfügen",
|
||||
"deleteRow": "Zeile löschen",
|
||||
"mergeCells": "Zellen verbinden",
|
||||
"splitCell": "Zelle teilen",
|
||||
"toggleHeaderRow": "Kopfzeile umschalten",
|
||||
"deleteTable": "Tabelle löschen"
|
||||
},
|
||||
@ -192,5 +194,6 @@
|
||||
"due": "Zieldatum",
|
||||
"start": "Startdatum"
|
||||
},
|
||||
"contentLabel": "Seiteninhalt"
|
||||
"contentLabel": "Seiteninhalt",
|
||||
"keyboardHint": "In Tabellen wechselt die Tabulatortaste zur nächsten Zelle und legt in der letzten Zelle eine neue Zeile an; Umschalt+Tab geht zurück. Escape stellt den Cursor hinter die Tabelle; außerhalb von Tabellen verlässt die Tabulatortaste den Editor."
|
||||
}
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
"rate_limited": "Zu viele Anfragen — bitte versuche es später erneut.",
|
||||
"internal_error": "Interner Serverfehler.",
|
||||
"registration_closed": "Die Registrierung ist auf dieser Instanz derzeit geschlossen.",
|
||||
"invitations_disabled": "Einladungen sind auf dieser Instanz deaktiviert.",
|
||||
"invitation_quota_reached": "Du hast die Höchstzahl offener Einladungen erreicht. Widerrufe eine offene Einladung oder warte, bis eine angenommen wurde oder abgelaufen ist.",
|
||||
"invitation_already_accepted": "Diese Einladung wurde bereits angenommen und kann nicht mehr widerrufen werden.",
|
||||
"token_invalid": "Dieser Link ist ungültig oder abgelaufen.",
|
||||
"login_failed": "Benutzername/E-Mail oder Passwort ist falsch.",
|
||||
"login_backoff": "Zu viele Fehlversuche — bitte warte ein paar Minuten.",
|
||||
|
||||
34
packages/shared/i18n/de/invitations.json
Normal file
34
packages/shared/i18n/de/invitations.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"section": {
|
||||
"title": "Einladungen",
|
||||
"intro": "Lade Personen per E-Mail ein. Der Link erlaubt genau eine Registrierung — auch wenn die Selbst-Registrierung geschlossen ist — und ist 14 Tage gültig.",
|
||||
"quota": "{{open}} von {{max}} offenen Einladungen belegt.",
|
||||
"disabled": "Einladungen sind auf dieser Instanz deaktiviert.",
|
||||
"empty": "Noch keine Einladungen."
|
||||
},
|
||||
"form": {
|
||||
"email": "E-Mail-Adresse",
|
||||
"submit": "Einladen",
|
||||
"sent": "Einladung verschickt."
|
||||
},
|
||||
"columns": {
|
||||
"email": "E-Mail",
|
||||
"status": "Status",
|
||||
"created": "Eingeladen am",
|
||||
"expires": "Gültig bis",
|
||||
"actions": "Aktionen"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Offen",
|
||||
"accepted": "Angenommen",
|
||||
"revoked": "Widerrufen",
|
||||
"expired": "Abgelaufen"
|
||||
},
|
||||
"actions": {
|
||||
"revoke": "Widerrufen"
|
||||
},
|
||||
"signup": {
|
||||
"banner": "{{inviterName}} lädt dich ein ({{email}}). Mit dieser Einladung kannst du dir jetzt ein Konto anlegen.",
|
||||
"invalid": "Dieser Einladungslink ist ungültig, abgelaufen oder wurde bereits verwendet."
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,12 @@
|
||||
"action": "Neues Passwort setzen",
|
||||
"expiry": "Der Link ist eine Stunde gültig. Dein aktuelles Passwort bleibt gültig, bis du ein neues gesetzt hast."
|
||||
},
|
||||
"invitation": {
|
||||
"subject": "Du bist eingeladen: Dorfteich",
|
||||
"body": "{{inviterName}} lädt dich zu einem Dorfteich ein — einem gemeinsamen Ort für Seiten und Notizen. Über diesen Link kannst du dir ein Konto anlegen:",
|
||||
"action": "Einladung annehmen",
|
||||
"expiry": "Der Link ist 14 Tage gültig und kann nur einmal verwendet werden."
|
||||
},
|
||||
"smtpTest": {
|
||||
"subject": "SMTP-Testnachricht",
|
||||
"body": "diese Testnachricht bestätigt, dass dein Dorfteich E-Mails über den konfigurierten SMTP-Server versenden kann. Deine Instanz erreichst du hier:",
|
||||
|
||||
@ -32,5 +32,20 @@
|
||||
},
|
||||
"prev": "Zurück",
|
||||
"next": "Weiter",
|
||||
"page": "Seite {{page}}"
|
||||
"page": "Seite {{page}}",
|
||||
"create": {
|
||||
"button": "Person anlegen",
|
||||
"title": "Person anlegen",
|
||||
"intro": "Das Konto ist sofort aktiv — es wird keine Bestätigungs-E-Mail verschickt. Teile das Passwort auf einem sicheren Weg mit.",
|
||||
"username": "Benutzername",
|
||||
"email": "E-Mail",
|
||||
"displayName": "Anzeigename",
|
||||
"password": "Anfangspasswort",
|
||||
"passwordHint": "Mindestens 10 Zeichen. Die Person sollte es nach dem ersten Login ändern.",
|
||||
"locale": "Sprache",
|
||||
"localeDe": "Deutsch",
|
||||
"localeEn": "Englisch",
|
||||
"submit": "Anlegen",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,5 +37,13 @@
|
||||
"size": "Crop edge length (px)",
|
||||
"reset": "Reset the crop",
|
||||
"result": "Result: {{width}} × {{height}} px (source image {{sourceWidth}} × {{sourceHeight}} px)."
|
||||
},
|
||||
"pond": {
|
||||
"intro": "This pond can carry its own logo and favicon. Both override the instance's — for this pond only.",
|
||||
"quotaNote": "The files count against this pond's storage quota, just like attachments.",
|
||||
"inherited": "Nothing set — the instance's setting applies.",
|
||||
"reset": "Reset to the instance setting",
|
||||
"darkMissing": "No separate dark-mode logo is set for this pond. The pond's light logo is then used there — NOT the instance's dark one. A logo set belongs to one level and is never mixed across levels. This is advice, not a block.",
|
||||
"faviconHint": "The favicon is applied to the tab when the pond is entered and restored on leaving. Opening a pond link directly shows the instance favicon briefly before it changes."
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,6 +76,8 @@
|
||||
"addRowBefore": "Add row before",
|
||||
"addRowAfter": "Add row after",
|
||||
"deleteRow": "Delete row",
|
||||
"mergeCells": "Merge cells",
|
||||
"splitCell": "Split cell",
|
||||
"toggleHeaderRow": "Toggle header row",
|
||||
"deleteTable": "Delete table"
|
||||
},
|
||||
@ -192,5 +194,6 @@
|
||||
"due": "Due date",
|
||||
"start": "Start date"
|
||||
},
|
||||
"contentLabel": "Page content"
|
||||
"contentLabel": "Page content",
|
||||
"keyboardHint": "Inside tables, Tab moves to the next cell and creates a new row from the last cell; Shift+Tab moves back. Escape places the cursor after the table; outside tables, Tab leaves the editor."
|
||||
}
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
"rate_limited": "Too many requests — please try again later.",
|
||||
"internal_error": "Internal server error.",
|
||||
"registration_closed": "Registration is currently closed on this instance.",
|
||||
"invitations_disabled": "Invitations are disabled on this instance.",
|
||||
"invitation_quota_reached": "You have reached the maximum number of open invitations. Revoke an open invitation or wait until one is accepted or expires.",
|
||||
"invitation_already_accepted": "This invitation has already been accepted and can no longer be revoked.",
|
||||
"token_invalid": "This link is invalid or has expired.",
|
||||
"login_failed": "Username/e-mail or password is incorrect.",
|
||||
"login_backoff": "Too many failed attempts — please wait a few minutes.",
|
||||
|
||||
34
packages/shared/i18n/en/invitations.json
Normal file
34
packages/shared/i18n/en/invitations.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"section": {
|
||||
"title": "Invitations",
|
||||
"intro": "Invite people by e-mail. The link allows exactly one registration — even while self-registration is closed — and is valid for 14 days.",
|
||||
"quota": "{{open}} of {{max}} open invitations used.",
|
||||
"disabled": "Invitations are disabled on this instance.",
|
||||
"empty": "No invitations yet."
|
||||
},
|
||||
"form": {
|
||||
"email": "E-mail address",
|
||||
"submit": "Invite",
|
||||
"sent": "Invitation sent."
|
||||
},
|
||||
"columns": {
|
||||
"email": "E-mail",
|
||||
"status": "Status",
|
||||
"created": "Invited on",
|
||||
"expires": "Valid until",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Open",
|
||||
"accepted": "Accepted",
|
||||
"revoked": "Revoked",
|
||||
"expired": "Expired"
|
||||
},
|
||||
"actions": {
|
||||
"revoke": "Revoke"
|
||||
},
|
||||
"signup": {
|
||||
"banner": "{{inviterName}} invites you ({{email}}). With this invitation you can create an account now.",
|
||||
"invalid": "This invitation link is invalid, expired, or has already been used."
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,12 @@
|
||||
"action": "Set new password",
|
||||
"expiry": "The link is valid for one hour. Your current password stays valid until you set a new one."
|
||||
},
|
||||
"invitation": {
|
||||
"subject": "You are invited: Dorfteich",
|
||||
"body": "{{inviterName}} invites you to a Dorfteich - a shared place for pages and notes. Use this link to create your account:",
|
||||
"action": "Accept invitation",
|
||||
"expiry": "The link is valid for 14 days and can only be used once."
|
||||
},
|
||||
"smtpTest": {
|
||||
"subject": "SMTP test message",
|
||||
"body": "this test message confirms that your Dorfteich can send e-mail through the configured SMTP server. You can reach your instance here:",
|
||||
|
||||
@ -32,5 +32,20 @@
|
||||
},
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {{page}}"
|
||||
"page": "Page {{page}}",
|
||||
"create": {
|
||||
"button": "Create user",
|
||||
"title": "Create user",
|
||||
"intro": "The account is active immediately — no verification mail is sent. Share the password over a secure channel.",
|
||||
"username": "Username",
|
||||
"email": "E-mail",
|
||||
"displayName": "Display name",
|
||||
"password": "Initial password",
|
||||
"passwordHint": "At least 10 characters. The user should change it after their first login.",
|
||||
"locale": "Language",
|
||||
"localeDe": "German",
|
||||
"localeEn": "English",
|
||||
"submit": "Create",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { passwordSchema, usernameSchema } from './auth';
|
||||
|
||||
/**
|
||||
* Site-Admin user management (issue #59): the list + actions an instance
|
||||
* operator uses for support, abuse handling, and GDPR groundwork. Deleting a
|
||||
@ -39,5 +41,21 @@ export type AdminUserListQuery = z.infer<typeof adminUserListQuerySchema>;
|
||||
export const setUserDisabledSchema = z.object({ disabled: z.boolean() });
|
||||
export const setSiteAdminSchema = z.object({ isSiteAdmin: z.boolean() });
|
||||
|
||||
/**
|
||||
* Direct account creation by a Site Admin (issue #331). Same field rules as
|
||||
* self-registration, but the account skips e-mail verification: the admin
|
||||
* vouches for the address, so the user can log in right away.
|
||||
*/
|
||||
export const adminCreateUserSchema = z.object({
|
||||
username: usernameSchema,
|
||||
email: z.string().email('validation.email.invalid').max(254),
|
||||
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
|
||||
password: passwordSchema,
|
||||
locale: z.enum(['de', 'en']).default('en'),
|
||||
});
|
||||
export type AdminCreateUserInput = z.infer<typeof adminCreateUserSchema>;
|
||||
/** Form-side type: locale is optional before Zod applies its default. */
|
||||
export type AdminCreateUserFormInput = z.input<typeof adminCreateUserSchema>;
|
||||
|
||||
/** The pseudonym a deleted user's authorship shows as. */
|
||||
export const DELETED_USER_DISPLAY_NAME = 'Deleted user';
|
||||
|
||||
@ -44,6 +44,9 @@ export const signupInputSchema = z.object({
|
||||
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
|
||||
password: passwordSchema,
|
||||
locale: z.enum(['de', 'en']).default('en'),
|
||||
/** Invitation token (issue #332): lets this one signup through even
|
||||
* while registration is closed. */
|
||||
invitationToken: z.string().min(16).max(256).optional(),
|
||||
});
|
||||
export type SignupInput = z.infer<typeof signupInputSchema>;
|
||||
/** Form-side type: locale is optional before Zod applies its default. */
|
||||
|
||||
97
packages/shared/src/branding.test.ts
Normal file
97
packages/shared/src/branding.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { pngDimensions, hasPngMagic, looksLikeSvg, resolveBranding } from './branding';
|
||||
|
||||
const asset = (hash: string) => ({ hash, width: 10, height: 10 });
|
||||
const NONE = { logo: null, logoDark: null, favicon: null };
|
||||
|
||||
/**
|
||||
* The resolution order (issues #306/#307) lives in one function, so this is
|
||||
* where the decision that most easily gets "fixed" by accident is pinned:
|
||||
* a logo set belongs to ONE level and variants are never mixed across levels.
|
||||
*/
|
||||
describe('resolveBranding', () => {
|
||||
it('prefers the pond over the instance', () => {
|
||||
const resolved = resolveBranding(
|
||||
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: null, favicon: null },
|
||||
{ ...NONE, logo: asset('bbbbbbbbbbbbbbbb') },
|
||||
);
|
||||
expect(resolved.logo?.hash).toBe('bbbbbbbbbbbbbbbb');
|
||||
expect(resolved.logoLevel).toBe('pond');
|
||||
});
|
||||
|
||||
it('keeps a pond on ITS OWN light logo in dark mode, never the instance dark one', () => {
|
||||
// Decided 2026-08-01: a logo silently swapping to a different image when
|
||||
// the viewer switches theme is a change nobody ordered. A design that
|
||||
// looks wrong is more honest than one that is quietly substituted.
|
||||
const resolved = resolveBranding(
|
||||
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: asset('cccccccccccccccc'), favicon: null },
|
||||
{ ...NONE, logo: asset('bbbbbbbbbbbbbbbb') },
|
||||
);
|
||||
expect(resolved.logo?.hash).toBe('bbbbbbbbbbbbbbbb');
|
||||
expect(resolved.logoDark).toBeNull();
|
||||
});
|
||||
|
||||
it('inherits BOTH instance variants when the pond has no logo at all', () => {
|
||||
const resolved = resolveBranding(
|
||||
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: asset('cccccccccccccccc'), favicon: null },
|
||||
NONE,
|
||||
);
|
||||
expect(resolved.logo?.hash).toBe('aaaaaaaaaaaaaaaa');
|
||||
expect(resolved.logoDark?.hash).toBe('cccccccccccccccc');
|
||||
expect(resolved.logoLevel).toBe('instance');
|
||||
});
|
||||
|
||||
it('treats a pond with only a DARK logo as a set of its own too', () => {
|
||||
const resolved = resolveBranding(
|
||||
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: null, favicon: null },
|
||||
{ ...NONE, logoDark: asset('dddddddddddddddd') },
|
||||
);
|
||||
expect(resolved.logo).toBeNull();
|
||||
expect(resolved.logoDark?.hash).toBe('dddddddddddddddd');
|
||||
});
|
||||
|
||||
it('falls through to nothing, which is the render-the-name case', () => {
|
||||
expect(resolveBranding(NONE, NONE).logoLevel).toBe('none');
|
||||
expect(resolveBranding(NONE, null).logo).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the favicon per level, down to the shipped default', () => {
|
||||
expect(resolveBranding(NONE, { ...NONE, favicon: asset('e'.repeat(16)) }).faviconLevel).toBe(
|
||||
'pond',
|
||||
);
|
||||
expect(resolveBranding({ ...NONE, favicon: asset('f'.repeat(16)) }, NONE).faviconLevel).toBe(
|
||||
'instance',
|
||||
);
|
||||
expect(resolveBranding(NONE, NONE).faviconLevel).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('image checks (no decoding)', () => {
|
||||
const png = (width: number, height: number): Uint8Array => {
|
||||
const buf = Buffer.alloc(33);
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0);
|
||||
buf.write('IHDR', 12, 'latin1');
|
||||
buf.writeUInt32BE(width, 16);
|
||||
buf.writeUInt32BE(height, 20);
|
||||
return buf;
|
||||
};
|
||||
|
||||
it('reads the dimensions out of the IHDR', () => {
|
||||
expect(pngDimensions(png(512, 128))).toEqual({ width: 512, height: 128 });
|
||||
});
|
||||
|
||||
it('refuses anything that is not a PNG with an IHDR first', () => {
|
||||
expect(hasPngMagic(Buffer.from('GIF89a'))).toBe(false);
|
||||
expect(pngDimensions(Buffer.from('GIF89a'))).toBeNull();
|
||||
// Truncated: a reader that trusted the signature alone would run off the
|
||||
// end here.
|
||||
expect(pngDimensions(Buffer.from([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
|
||||
});
|
||||
|
||||
it('recognises SVG so the rejection can say why', () => {
|
||||
expect(looksLikeSvg(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg">'))).toBe(true);
|
||||
expect(looksLikeSvg(Buffer.from('<?xml version="1.0"?>\n<svg>'))).toBe(true);
|
||||
expect(looksLikeSvg(png(1, 1))).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -81,6 +81,10 @@ export const brandingAssetSchema = z.object({
|
||||
.regex(/^[a-f0-9]{16,64}$/),
|
||||
width: z.number().int().min(1),
|
||||
height: z.number().int().min(1),
|
||||
/** Stored bytes. Needed so a pond's quota can be released exactly when the
|
||||
* asset is replaced or removed (issue #307) — optional because instance
|
||||
* assets predating it are not charged to anything. */
|
||||
byteSize: z.number().int().min(0).optional(),
|
||||
});
|
||||
export type BrandingAsset = z.infer<typeof brandingAssetSchema>;
|
||||
|
||||
@ -96,3 +100,54 @@ export interface BrandingView {
|
||||
* screen carries the branding. */
|
||||
instanceName: string;
|
||||
}
|
||||
|
||||
/** A pond's own branding (issue #307), stored in its settings. Null per slot
|
||||
* means "not set at this level". */
|
||||
export const pondBrandingSchema = z.object({
|
||||
logo: brandingAssetSchema.nullable().default(null),
|
||||
logoDark: brandingAssetSchema.nullable().default(null),
|
||||
favicon: brandingAssetSchema.nullable().default(null),
|
||||
});
|
||||
export type PondBranding = z.infer<typeof pondBrandingSchema>;
|
||||
|
||||
/** What a pond page should actually show. */
|
||||
export interface ResolvedBranding {
|
||||
logo: BrandingAsset | null;
|
||||
logoDark: BrandingAsset | null;
|
||||
favicon: BrandingAsset | null;
|
||||
/** Which level the LOGO came from — the link's accessible name follows it:
|
||||
* a pond logo is named by the pond, an instance logo by the instance. */
|
||||
logoLevel: 'pond' | 'instance' | 'none';
|
||||
faviconLevel: 'pond' | 'instance' | 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* The single place that decides which asset applies (issues #306/#307):
|
||||
* the pond's own, else the instance's, else the shipped default (favicon) or
|
||||
* the instance name as text (logo).
|
||||
*
|
||||
* **A logo set belongs to one level — variants are NEVER mixed across
|
||||
* levels.** A pond that uploaded only a light logo shows THAT logo in dark
|
||||
* mode; it does not fall back to the instance's dark variant. Decided
|
||||
* 2026-08-01: a logo silently swapping to a different image when the viewer
|
||||
* switches theme is a change nobody ordered, and a design that looks wrong is
|
||||
* more honest than one that is quietly substituted — the pond admin can see
|
||||
* it and fix it. Only a pond with NO logo at all inherits the instance's set,
|
||||
* again as a set.
|
||||
*/
|
||||
export function resolveBranding(
|
||||
instance: Pick<BrandingView, 'logo' | 'logoDark' | 'favicon'>,
|
||||
pond?: PondBranding | null,
|
||||
): ResolvedBranding {
|
||||
const pondHasLogo = Boolean(pond && (pond.logo || pond.logoDark));
|
||||
const logoLevel = pondHasLogo ? 'pond' : instance.logo || instance.logoDark ? 'instance' : 'none';
|
||||
const source = pondHasLogo ? pond! : instance;
|
||||
const faviconLevel = pond?.favicon ? 'pond' : instance.favicon ? 'instance' : 'default';
|
||||
return {
|
||||
logo: logoLevel === 'none' ? null : source.logo,
|
||||
logoDark: logoLevel === 'none' ? null : source.logoDark,
|
||||
favicon: pond?.favicon ?? instance.favicon ?? null,
|
||||
logoLevel,
|
||||
faviconLevel,
|
||||
};
|
||||
}
|
||||
|
||||
@ -25,6 +25,26 @@ describe('docToHtml (issue #24)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the cell spans of merged cells (issue #337)', () => {
|
||||
// Built directly: markdown cannot express merged cells.
|
||||
const cell = (text: string, attrs: { colspan?: number; rowspan?: number } | null = null) =>
|
||||
editorSchema.node('table_cell', attrs, [
|
||||
editorSchema.node('paragraph', null, [editorSchema.text(text)]),
|
||||
]);
|
||||
const doc = editorSchema.node('doc', null, [
|
||||
editorSchema.node('table', null, [
|
||||
editorSchema.node('table_row', null, [
|
||||
cell('wide', { colspan: 2 }),
|
||||
cell('tall', { rowspan: 2 }),
|
||||
]),
|
||||
editorSchema.node('table_row', null, [cell('a'), cell('b')]),
|
||||
]),
|
||||
]);
|
||||
expect(docToHtml(doc)).toBe(
|
||||
'<table><tr><td colspan="2"><p>wide</p></td><td rowspan="2"><p>tall</p></td></tr><tr><td><p>a</p></td><td><p>b</p></td></tr></table>',
|
||||
);
|
||||
});
|
||||
|
||||
it('allowlists link protocols, neutralizing javascript: hrefs', () => {
|
||||
const safe = markdownToDoc('[go](https://example.org)');
|
||||
expect(docToHtml(safe)).toContain('href="https://example.org"');
|
||||
|
||||
@ -111,7 +111,12 @@ function renderTable(node: Node): string {
|
||||
out += '<tr>';
|
||||
row.forEach((cell) => {
|
||||
const tag = cell.type.name === 'table_header' ? 'th' : 'td';
|
||||
out += `<${tag}>${renderBlocks(cell)}</${tag}>`;
|
||||
// Merged cells (issue #337): without the span attributes the read-mode
|
||||
// table silently loses the merge the editor shows.
|
||||
const colspan = cell.attrs.colspan as number;
|
||||
const rowspan = cell.attrs.rowspan as number;
|
||||
const spans = `${colspan > 1 ? ` colspan="${colspan}"` : ''}${rowspan > 1 ? ` rowspan="${rowspan}"` : ''}`;
|
||||
out += `<${tag}${spans}>${renderBlocks(cell)}</${tag}>`;
|
||||
});
|
||||
out += '</tr>';
|
||||
});
|
||||
|
||||
@ -148,6 +148,21 @@ describe('markdown round-trip (issue #24)', () => {
|
||||
expect(doc.firstChild?.attrs.data).toEqual({});
|
||||
});
|
||||
|
||||
it('pads a colspan cell so every row keeps the column count (issue #337)', () => {
|
||||
// Built directly: markdown cannot express merged cells, so the export
|
||||
// is lossy by format — but it must stay a well-formed GFM table.
|
||||
const schema = markdownToDoc('x').type.schema;
|
||||
const cell = (type: string, text: string, attrs: { colspan?: number } | null = null) =>
|
||||
schema.node(type, attrs, [schema.node('paragraph', null, [schema.text(text)])]);
|
||||
const doc = schema.node('doc', null, [
|
||||
schema.node('table', null, [
|
||||
schema.node('table_row', null, [cell('table_header', 'A'), cell('table_header', 'B')]),
|
||||
schema.node('table_row', null, [cell('table_cell', 'wide', { colspan: 2 })]),
|
||||
]),
|
||||
]);
|
||||
expect(docToMarkdown(doc)).toBe('| A | B |\n| --- | --- |\n| wide | |');
|
||||
});
|
||||
|
||||
it('escalates the fence when the data contains backticks (issue #76)', () => {
|
||||
const doc = markdownToDoc('```dorfteich-plugin p/t\n{"code":"x"}\n```');
|
||||
const withTicks = doc.type.schema.nodes.plugin_block!.create({
|
||||
|
||||
@ -510,6 +510,10 @@ function renderTable(state: MarkdownSerializerState, node: Node): void {
|
||||
const cells: string[] = [];
|
||||
row.forEach((cell) => {
|
||||
cells.push(cell.textContent.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim());
|
||||
// GFM has no cell spans: pad a colspan with empty cells so every row
|
||||
// keeps the table's column count (rowspan stays lossy — the covered
|
||||
// rows are simply shorter; issue #337).
|
||||
for (let extra = 1; extra < (cell.attrs.colspan as number); extra += 1) cells.push('');
|
||||
});
|
||||
rows.push(cells);
|
||||
});
|
||||
|
||||
@ -18,6 +18,7 @@ export * from './fonts';
|
||||
export * from './health';
|
||||
export * from './home';
|
||||
export * from './i18n-tools';
|
||||
export * from './invitations';
|
||||
export * from './labels';
|
||||
export * from './legal';
|
||||
export * from './links';
|
||||
|
||||
40
packages/shared/src/invitations.ts
Normal file
40
packages/shared/src/invitations.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Peer invitations (issue #332): a user invites an e-mail address; the
|
||||
* mailed token allows exactly one registration even while registration
|
||||
* is closed. A per-user quota on OPEN (pending, unexpired) invitations —
|
||||
* the instance setting `invitations.maxOpenPerUser`, default 5, 0 turns
|
||||
* the feature off — keeps the feature from becoming a spam channel.
|
||||
*/
|
||||
|
||||
export type InvitationStatus = 'pending' | 'accepted' | 'revoked' | 'expired';
|
||||
|
||||
export interface InvitationView {
|
||||
id: string;
|
||||
email: string;
|
||||
status: InvitationStatus;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface InvitationListView {
|
||||
invitations: InvitationView[];
|
||||
/** Open (pending, unexpired) invitations counted against the quota. */
|
||||
open: number;
|
||||
/** The instance-wide per-user quota; 0 = inviting disabled. */
|
||||
maxOpen: number;
|
||||
}
|
||||
|
||||
export const createInvitationSchema = z.object({
|
||||
email: z.string().email('validation.email.invalid').max(254),
|
||||
});
|
||||
export type CreateInvitationInput = z.infer<typeof createInvitationSchema>;
|
||||
|
||||
export const invitationPreviewSchema = z.object({ token: z.string().min(16).max(256) });
|
||||
|
||||
/** What the signup screen shows about an invitation link before use. */
|
||||
export interface InvitationPreview {
|
||||
email: string;
|
||||
inviterName: string;
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { pondBrandingSchema } from './branding';
|
||||
|
||||
import { COMMENT_POLICIES } from './comments';
|
||||
|
||||
/**
|
||||
@ -66,6 +68,12 @@ export const pondSettingsSchema = z.object({
|
||||
* 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),
|
||||
/** The pond's own logo and favicon (issue #307), overriding the instance's.
|
||||
* Metadata only — the PNG bytes live under `BRANDING_DIR`, like the
|
||||
* instance's. Written through the branding endpoints, never through
|
||||
* `PATCH /ponds/:id`: it describes bytes on disk, and hand-writing it would
|
||||
* claim an asset that is not there. */
|
||||
branding: pondBrandingSchema.default({ logo: null, logoDark: null, favicon: null }),
|
||||
/** Who may write comments (issue #91): every reader, or editors only. */
|
||||
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
|
||||
/** Per-pond opt-in to the public REST API (issue #104, default off):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user