From 7f1c49db53dccd817079e5e7ba817d7b8fd8738b Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Thu, 9 Jul 2026 20:22:52 +0200 Subject: [PATCH] Add pond member management UI (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pond Admins manage who participates in a pond, by role, with editor/reader seat quotas — the member-facing layer over the grant model (#51/#52). - shared: `MemberView`/`PondMembersView` + add/change-role schemas (`members.ts`), a `members` i18n namespace (de+en), and member error codes. - api `members/`: a member-centric API over pond-scope user grants — `GET /ponds/:id/members` (any member, for transparency: list grouped by effective role + seat usage + `canManage`), `POST` (add by exact username or e-mail — no directory browsing), `PATCH :userId` (change role), `DELETE :userId` (remove), all Pond-Admin-gated by the guard. Editor/reader seats are enforced against `editors_per_pond`/`readers_per_pond` (#22) inside a per-pond advisory-locked transaction so counts cannot race; the owner's membership is protected, personal ponds refuse a second admin (shared grant rule), and the last Pond Admin cannot be dropped. Every change invalidates the pond permission cache and fires the access NOTIFY (#39/#53). - web `members/`: `MemberManager` in Pond Settings — list grouped by role with a search filter and seat usage, add-by-identifier form (disabled with a localized explanation when the chosen role's seats are full), per-member role change and remove; read-only for non-admins; the personal-pond rule is surfaced. There is no invitation flow (v1): adding is immediate, and the copy says so. - tests: `members.e2e.db.test.ts` (add/change/remove, seat exhaustion, personal-pond and owner rules, read-only transparency, last-admin) and a `members` browser pack (immediate second-browser access, quota disables the add action, non-admin read-only) with its own CI step. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .gitea/workflows/ci.yml | 11 + apps/api/src/app.module.ts | 2 + apps/api/src/members/members.controller.ts | 65 +++++ apps/api/src/members/members.e2e.db.test.ts | 251 ++++++++++++++++ apps/api/src/members/members.module.ts | 21 ++ apps/api/src/members/members.service.ts | 301 ++++++++++++++++++++ apps/web/e2e/members.spec.ts | 100 +++++++ apps/web/src/i18n/index.ts | 4 + apps/web/src/members/MemberManager.tsx | 243 ++++++++++++++++ apps/web/src/members/use-pond-members.ts | 42 +++ apps/web/src/pages/PondSettingsPage.tsx | 16 +- apps/web/src/styles/base.css | 105 +++++++ packages/shared/i18n/de/errors.json | 4 + packages/shared/i18n/de/members.json | 40 +++ packages/shared/i18n/en/errors.json | 4 + packages/shared/i18n/en/members.json | 40 +++ packages/shared/src/index.ts | 1 + packages/shared/src/members.ts | 57 ++++ 18 files changed, 1303 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/members/members.controller.ts create mode 100644 apps/api/src/members/members.e2e.db.test.ts create mode 100644 apps/api/src/members/members.module.ts create mode 100644 apps/api/src/members/members.service.ts create mode 100644 apps/web/e2e/members.spec.ts create mode 100644 apps/web/src/members/MemberManager.tsx create mode 100644 apps/web/src/members/use-pond-members.ts create mode 100644 packages/shared/i18n/de/members.json create mode 100644 packages/shared/i18n/en/members.json create mode 100644 packages/shared/src/members.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1c7395f..359cde3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -166,6 +166,17 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/collab-permissions.spec.ts + # Two contexts per test (owner + a second regular account) → reset first. + - name: Reset login rate limit before members 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 members pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/members.spec.ts + - name: Reset login rate limit before offline pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 7fa47f7..173d11c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -14,6 +14,7 @@ import { HealthModule } from './health/health.module'; import { LabelsModule } from './labels/labels.module'; import { LinksModule } from './links/links.module'; import { MailModule } from './mail/mail.module'; +import { MembersModule } from './members/members.module'; import { PagesModule } from './pages/pages.module'; import { PermissionsModule } from './permissions/permissions.module'; import { PondsModule } from './ponds/ponds.module'; @@ -44,6 +45,7 @@ import { VersionsModule } from './versions/versions.module'; LinksModule, SearchModule, GrantsModule, + MembersModule, AuthModule, AdminModule, LoggerModule.forRootAsync({ diff --git a/apps/api/src/members/members.controller.ts b/apps/api/src/members/members.controller.ts new file mode 100644 index 0000000..0a3708a --- /dev/null +++ b/apps/api/src/members/members.controller.ts @@ -0,0 +1,65 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Req } from '@nestjs/common'; +import { + AddMemberInput, + ChangeMemberRoleInput, + MemberView, + PondMembersView, + addMemberInputSchema, + changeMemberRoleInputSchema, +} from '@dorfteich/shared'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { RequiresPondRole } from '../permissions/permission.decorators'; +import { MembersService } from './members.service'; + +/** + * Pond member management (issue #54). Listing is open to any member so the + * membership is transparent (read-only for non-admins); adding, changing a + * role, and removing are Pond-Admin-gated by the permission guard. + */ +@Controller('ponds/:pondId/members') +export class MembersController { + constructor(private readonly members: MembersService) {} + + @Get() + @RequiresPondRole('reader', { idParam: 'pondId' }) + async list( + @Param('pondId') pondId: string, + @Req() request: AuthedRequest, + ): Promise { + return this.members.list(request.user ?? null, pondId); + } + + @Post() + @RequiresPondRole('pond_admin', { idParam: 'pondId' }) + async add( + @Param('pondId') pondId: string, + @Body(new ZodValidationPipe(addMemberInputSchema)) input: AddMemberInput, + @Req() request: AuthedRequest, + ): Promise { + return this.members.add(request.user!, pondId, input); + } + + @Patch(':userId') + @RequiresPondRole('pond_admin', { idParam: 'pondId' }) + async changeRole( + @Param('pondId') pondId: string, + @Param('userId') userId: string, + @Body(new ZodValidationPipe(changeMemberRoleInputSchema)) input: ChangeMemberRoleInput, + @Req() request: AuthedRequest, + ): Promise { + return this.members.changeRole(request.user!, pondId, userId, input); + } + + @Delete(':userId') + @HttpCode(204) + @RequiresPondRole('pond_admin', { idParam: 'pondId' }) + async remove( + @Param('pondId') pondId: string, + @Param('userId') userId: string, + @Req() request: AuthedRequest, + ): Promise { + await this.members.remove(request.user!, pondId, userId); + } +} diff --git a/apps/api/src/members/members.e2e.db.test.ts b/apps/api/src/members/members.e2e.db.test.ts new file mode 100644 index 0000000..0fbbf40 --- /dev/null +++ b/apps/api/src/members/members.e2e.db.test.ts @@ -0,0 +1,251 @@ +import { INestApplication } from '@nestjs/common'; +import { PondMembersView } from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { PondsService } from '../ponds/ponds.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** + * Pond member management end to end (issue #54): add/change/remove by role, + * seat quotas, personal-pond and owner rules, and read-only transparency for + * non-admin members — all through HTTP so the guard and the service are proven + * together. + */ +describe.skipIf(!hasTestDb)('pond members (e2e, issue #54)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'mitglieder brauchen keine einladung 1'; + + const userIds: Record = {}; + const emails: Record = {}; + const cookies: Record = {}; + + let pondId: string; + let personalPondId: string; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string): Promise { + const users = app.get(UsersService); + const username = `mem-${handle}-${suffix}`; + const email = `${username}@example.org`; + const user = await users.createUser({ + username, + email, + displayName: `Mem ${handle}`, + password, + locale: 'en', + }); + userIds[handle] = user.id; + emails[handle] = email; + await users.markEmailVerified(user.id); + const res = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200); + cookies[handle] = sessionCookieOf(res); + } + + const membersOf = async (as: string): Promise => { + const res = await api() + .get(`/api/v1/ponds/${pondId}/members`) + .set('Cookie', cookies[as]!) + .expect(200); + return res.body as PondMembersView; + }; + + const usernameOf = (handle: string) => `mem-${handle}-${suffix}`; + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + + for (const handle of ['owner', 'alice', 'bob', 'carol']) await makeUser(handle); + + const owner = await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } }); + await app.get(PondsService).ensurePersonalPond(owner); + personalPondId = ( + await prisma.pond.findFirstOrThrow({ + where: { ownerId: userIds.owner!, type: 'PERSONAL' }, + select: { id: true }, + }) + ).id; + await prisma.quotaOverride.create({ + data: { + subjectType: 'USER', + subjectId: userIds.owner!, + quotaKey: 'additional_ponds', + value: 10, + }, + }); + + const pond = await api() + .post('/api/v1/ponds') + .set('Cookie', cookies.owner!) + .send({ name: `Members Pond ${suffix}` }) + .expect(201); + pondId = (pond.body as { id: string }).id; + }); + + afterAll(async () => { + const ids = Object.values(userIds); + await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); + await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } }); + await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('lists the owner as the sole admin member initially', async () => { + const view = await membersOf('owner'); + expect(view.pondType).toBe('shared'); + expect(view.canManage).toBe(true); + expect(view.members).toHaveLength(1); + expect(view.members[0]).toMatchObject({ + userId: userIds.owner, + role: 'pond_admin', + isOwner: true, + }); + expect(view.seats).toEqual({ + editor: { used: 0, limit: 5 }, + reader: { used: 0, limit: 50 }, + }); + }); + + it('adds a member by username and by e-mail; both take effect immediately', async () => { + await api() + .post(`/api/v1/ponds/${pondId}/members`) + .set('Cookie', cookies.owner!) + .send({ usernameOrEmail: usernameOf('alice'), role: 'reader' }) + .expect(201); + await api() + .post(`/api/v1/ponds/${pondId}/members`) + .set('Cookie', cookies.owner!) + .send({ usernameOrEmail: emails.bob, role: 'editor' }) + .expect(201); + + const view = await membersOf('owner'); + expect(view.members.map((m) => m.userId).sort()).toEqual( + [userIds.owner!, userIds.alice!, userIds.bob!].sort(), + ); + expect(view.seats.editor).toEqual({ used: 1, limit: 5 }); + // The new reader can immediately read the pond (and see the member list). + await api().get(`/api/v1/ponds/${pondId}/members`).set('Cookie', cookies.alice!).expect(200); + }); + + it('rejects unknown users and duplicate members', async () => { + await api() + .post(`/api/v1/ponds/${pondId}/members`) + .set('Cookie', cookies.owner!) + .send({ usernameOrEmail: `ghost-${suffix}`, role: 'reader' }) + .expect(400) + .expect((r) => expect((r.body as { code: string }).code).toBe('member_not_found')); + await api() + .post(`/api/v1/ponds/${pondId}/members`) + .set('Cookie', cookies.owner!) + .send({ usernameOrEmail: usernameOf('alice'), role: 'editor' }) + .expect(409) + .expect((r) => expect((r.body as { code: string }).code).toBe('member_exists')); + }); + + it('non-admin members see the list read-only but cannot manage', async () => { + const view = await membersOf('alice'); + expect(view.canManage).toBe(false); + expect(view.members.length).toBeGreaterThan(0); + await api() + .post(`/api/v1/ponds/${pondId}/members`) + .set('Cookie', cookies.alice!) + .send({ usernameOrEmail: usernameOf('carol'), role: 'reader' }) + .expect(403); + await api() + .patch(`/api/v1/ponds/${pondId}/members/${userIds.bob}`) + .set('Cookie', cookies.alice!) + .send({ role: 'reader' }) + .expect(403); + }); + + it('enforces the editor seat quota and re-enables when it is raised', async () => { + // Pin the pond to a single editor seat; bob already holds it. + await prisma.quotaOverride.create({ + data: { + subjectType: 'POND', + subjectId: pondId, + quotaKey: 'editors_per_pond', + value: 1, + }, + }); + await api() + .patch(`/api/v1/ponds/${pondId}/members/${userIds.alice}`) + .set('Cookie', cookies.owner!) + .send({ role: 'editor' }) + .expect(403) + .expect((r) => expect((r.body as { code: string }).code).toBe('quota_exceeded')); + + await prisma.quotaOverride.update({ + where: { + subjectType_subjectId_quotaKey: { + subjectType: 'POND', + subjectId: pondId, + quotaKey: 'editors_per_pond', + }, + }, + data: { value: 5 }, + }); + await api() + .patch(`/api/v1/ponds/${pondId}/members/${userIds.alice}`) + .set('Cookie', cookies.owner!) + .send({ role: 'editor' }) + .expect(200) + .expect((r) => expect((r.body as { role: string }).role).toBe('editor')); + }); + + it('shared ponds allow a second admin; personal ponds refuse one', async () => { + await api() + .patch(`/api/v1/ponds/${pondId}/members/${userIds.bob}`) + .set('Cookie', cookies.owner!) + .send({ role: 'pond_admin' }) + .expect(200); + const admins = (await membersOf('owner')).members.filter((m) => m.role === 'pond_admin'); + expect(admins.map((m) => m.userId).sort()).toEqual([userIds.owner!, userIds.bob!].sort()); + + await api() + .post(`/api/v1/ponds/${personalPondId}/members`) + .set('Cookie', cookies.owner!) + .send({ usernameOrEmail: usernameOf('alice'), role: 'pond_admin' }) + .expect(400) + .expect((r) => + expect((r.body as { code: string }).code).toBe('grant_pond_admin_personal_pond'), + ); + }); + + it("refuses to manage the owner's own membership", async () => { + await api() + .patch(`/api/v1/ponds/${pondId}/members/${userIds.owner}`) + .set('Cookie', cookies.owner!) + .send({ role: 'reader' }) + .expect(409) + .expect((r) => expect((r.body as { code: string }).code).toBe('member_is_owner')); + await api() + .delete(`/api/v1/ponds/${pondId}/members/${userIds.owner}`) + .set('Cookie', cookies.owner!) + .expect(409); + }); + + it('removes a member; access is gone on the next request', async () => { + await api() + .delete(`/api/v1/ponds/${pondId}/members/${userIds.alice}`) + .set('Cookie', cookies.owner!) + .expect(204); + const view = await membersOf('owner'); + expect(view.members.map((m) => m.userId)).not.toContain(userIds.alice); + // Alice lost her reader grant → the pond (and its member list) hides itself. + await api().get(`/api/v1/ponds/${pondId}/members`).set('Cookie', cookies.alice!).expect(404); + }); +}); diff --git a/apps/api/src/members/members.module.ts b/apps/api/src/members/members.module.ts new file mode 100644 index 0000000..d11fdd5 --- /dev/null +++ b/apps/api/src/members/members.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; + +import { PondsModule } from '../ponds/ponds.module'; +import { QuotasModule } from '../quotas/quotas.module'; +import { UsersModule } from '../users/users.module'; + +import { MembersController } from './members.controller'; +import { MembersService } from './members.service'; + +/** + * Pond member management (issue #54): the member-centric endpoints under + * `/ponds/:id/members` on top of the grant model (#51/#52), with editor/reader + * seat quotas (#22). PermissionService and the pond-permission cache come from + * the global PermissionsModule; the access notifier from PondsModule. + */ +@Module({ + imports: [PondsModule, QuotasModule, UsersModule], + controllers: [MembersController], + providers: [MembersService], +}) +export class MembersModule {} diff --git a/apps/api/src/members/members.service.ts b/apps/api/src/members/members.service.ts new file mode 100644 index 0000000..840caad --- /dev/null +++ b/apps/api/src/members/members.service.ts @@ -0,0 +1,301 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + AddMemberInput, + ChangeMemberRoleInput, + Grant, + MemberRole, + MemberView, + PondMembersView, + grantValidationError, +} from '@dorfteich/shared'; +import { Pond, Prisma, User } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { toGrant, toGrantColumns } from '../grants/grant-mappers'; +import { PermissionService } from '../permissions/permission.service'; +import { PondPermissionCache } from '../permissions/pond-permission-cache'; +import { PondAccessNotifier } from '../ponds/pond-access-notifier.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { QuotaService, quotaExceeded } from '../quotas/quota.service'; +import { UsersService } from '../users/users.service'; + +/** Highest capability wins when a user somehow holds several pond-scope grants. */ +const ROLE_RANK: Record = { reader: 0, editor: 1, pond_admin: 2 }; + +/** + * Pond membership management (issue #54): a member-centric view over the + * pond-scope user grants (#51/#52). Adding by exact username/e-mail keeps the + * user directory private; editor/reader seats are enforced against the + * per-pond quotas (#22). Personal-pond and last-admin rules come from the + * shared grant model and the grant table, respectively. Every change + * invalidates the pond's permission cache and notifies the collab server so + * live sessions re-validate at once (#39/#53). + */ +@Injectable() +export class MembersService { + constructor( + private readonly prisma: PrismaService, + private readonly users: UsersService, + private readonly quotas: QuotaService, + private readonly permissions: PermissionService, + private readonly permissionCache: PondPermissionCache, + private readonly accessNotifier: PondAccessNotifier, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(MembersService.name); + } + + private async requireLivePond(pondId: string): Promise { + const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); + if (!pond) throw new NotFoundException(); + return pond; + } + + private static pondScopeUserGrant(pondId: string, userId: string) { + return { + pondId, + subjectType: 'USER' as const, + subjectId: userId, + scopeType: 'POND' as const, + effect: 'ALLOW' as const, + }; + } + + private grantOf(role: MemberRole, userId: string): Grant { + return { + subjectType: 'user', + subjectId: userId, + role, + scopeType: 'pond', + scopeId: null, + effect: 'allow', + }; + } + + private static seatKey(role: 'editor' | 'reader'): 'editors_per_pond' | 'readers_per_pond' { + return role === 'editor' ? 'editors_per_pond' : 'readers_per_pond'; + } + + /** The member list, seat usage, and whether the caller may manage (issue #54). */ + async list(user: User | null, pondId: string): Promise { + const pond = await this.requireLivePond(pondId); + + const grants = await this.prisma.roleGrant.findMany({ + where: { pondId, subjectType: 'USER', scopeType: 'POND', effect: 'ALLOW' }, + }); + // A user's effective role is the strongest grant they hold at pond scope. + const roleByUser = new Map(); + for (const row of grants) { + const userId = row.subjectId; + if (!userId) continue; + const role = toGrant(row).role; + const current = roleByUser.get(userId); + if (!current || ROLE_RANK[role] > ROLE_RANK[current]) roleByUser.set(userId, role); + } + + const users = await this.prisma.user.findMany({ + where: { id: { in: [...roleByUser.keys()] } }, + select: { id: true, username: true, displayName: true }, + }); + const members: MemberView[] = users + .map((u) => ({ + userId: u.id, + username: u.username, + displayName: u.displayName, + role: roleByUser.get(u.id)!, + isOwner: u.id === pond.ownerId, + })) + .sort( + (a, b) => + ROLE_RANK[b.role] - ROLE_RANK[a.role] || a.displayName.localeCompare(b.displayName), + ); + + const editorLimit = await this.quotas.getEffective('editors_per_pond', { + pondId, + userId: pond.ownerId, + }); + const readerLimit = await this.quotas.getEffective('readers_per_pond', { + pondId, + userId: pond.ownerId, + }); + const canManage = await this.permissions.hasPondRole(user, pondId, 'pond_admin'); + + return { + members, + seats: { + editor: { used: members.filter((m) => m.role === 'editor').length, limit: editorLimit }, + reader: { used: members.filter((m) => m.role === 'reader').length, limit: readerLimit }, + }, + pondType: pond.type === 'PERSONAL' ? 'personal' : 'shared', + canManage, + }; + } + + /** Add a user (by exact username or e-mail) as a member with a role. */ + async add(actor: User, pondId: string, input: AddMemberInput): Promise { + const pond = await this.requireLivePond(pondId); + const target = await this.users.findByUsernameOrEmail(input.usernameOrEmail); + if (!target) throw new BadRequestException({ code: 'member_not_found' }); + + const grant = this.grantOf(input.role, target.id); + this.assertGrantValid(grant, pond); + + await this.prisma.$transaction(async (tx) => { + await this.lockMembership(tx, pondId); + const existing = await tx.roleGrant.count({ + where: MembersService.pondScopeUserGrant(pondId, target.id), + }); + if (existing > 0) throw new ConflictException({ code: 'member_exists' }); + await this.assertSeatAvailable(tx, pond, input.role, null); + await tx.roleGrant.create({ + data: { pondId, createdBy: actor.id, ...toGrantColumns(grant) }, + }); + }); + + await this.accessChanged(pondId); + this.logger.info( + { pondId, actor: actor.id, member: target.id, role: input.role }, + 'audit: member added', + ); + return this.viewOf(target, input.role, pond); + } + + /** Change a member's role, replacing their pond-scope grant. */ + async changeRole( + actor: User, + pondId: string, + memberUserId: string, + input: ChangeMemberRoleInput, + ): Promise { + const pond = await this.requireLivePond(pondId); + if (memberUserId === pond.ownerId) throw new ConflictException({ code: 'member_is_owner' }); + const target = await this.prisma.user.findUnique({ + where: { id: memberUserId }, + select: { id: true, username: true, displayName: true }, + }); + if (!target) throw new NotFoundException({ code: 'member_not_a_member' }); + + const grant = this.grantOf(input.role, memberUserId); + this.assertGrantValid(grant, pond); + + const changed = await this.prisma.$transaction(async (tx) => { + await this.lockMembership(tx, pondId); + const current = await tx.roleGrant.findMany({ + where: MembersService.pondScopeUserGrant(pondId, memberUserId), + }); + if (current.length === 0) throw new NotFoundException({ code: 'member_not_a_member' }); + // Already exactly this role: nothing to do. + if (current.length === 1 && toGrant(current[0]!).role === input.role) return false; + // Demoting away from admin must leave at least one Pond Admin behind. + if (current.some((g) => g.role === 'POND_ADMIN') && input.role !== 'pond_admin') { + await this.assertNotLastAdmin(tx, pondId); + } + // The replaced grants free their own seat, so exclude this user from the count. + await this.assertSeatAvailable(tx, pond, input.role, memberUserId); + await tx.roleGrant.deleteMany({ + where: MembersService.pondScopeUserGrant(pondId, memberUserId), + }); + await tx.roleGrant.create({ + data: { pondId, createdBy: actor.id, ...toGrantColumns(grant) }, + }); + return true; + }); + + if (changed) { + await this.accessChanged(pondId); + this.logger.info( + { pondId, actor: actor.id, member: memberUserId, role: input.role }, + 'audit: member role changed', + ); + } + return this.viewOf(target, input.role, pond); + } + + /** Remove a member (drops their pond-scope grant). */ + async remove(actor: User, pondId: string, memberUserId: string): Promise { + const pond = await this.requireLivePond(pondId); + if (memberUserId === pond.ownerId) throw new ConflictException({ code: 'member_is_owner' }); + + await this.prisma.$transaction(async (tx) => { + await this.lockMembership(tx, pondId); + const current = await tx.roleGrant.findMany({ + where: MembersService.pondScopeUserGrant(pondId, memberUserId), + }); + if (current.length === 0) throw new NotFoundException({ code: 'member_not_a_member' }); + if (current.some((g) => g.role === 'POND_ADMIN')) await this.assertNotLastAdmin(tx, pondId); + await tx.roleGrant.deleteMany({ + where: MembersService.pondScopeUserGrant(pondId, memberUserId), + }); + }); + + await this.accessChanged(pondId); + this.logger.info({ pondId, actor: actor.id, member: memberUserId }, 'audit: member removed'); + } + + private assertGrantValid(grant: Grant, pond: Pond): void { + const invalid = grantValidationError(grant, { + pondType: pond.type === 'PERSONAL' ? 'personal' : 'shared', + }); + if (invalid) throw new BadRequestException({ code: invalid }); + } + + /** Serialize concurrent membership changes on one pond (seat counts can't race). */ + private async lockMembership(tx: Prisma.TransactionClient, pondId: string): Promise { + // ::text because Prisma cannot deserialize the function's void result. + await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext(${`members:${pondId}`}))::text`; + } + + private async assertNotLastAdmin(tx: Prisma.TransactionClient, pondId: string): Promise { + const admins = await tx.roleGrant.count({ + where: { pondId, role: 'POND_ADMIN', effect: 'ALLOW' }, + }); + if (admins <= 1) throw new ConflictException({ code: 'grant_last_admin' }); + } + + /** Enforce editor/reader seat quotas; admins are unlimited. */ + private async assertSeatAvailable( + tx: Prisma.TransactionClient, + pond: Pond, + role: MemberRole, + excludeUserId: string | null, + ): Promise { + if (role === 'pond_admin') return; + const key = MembersService.seatKey(role); + const limit = await this.quotas.getEffective(key, { pondId: pond.id, userId: pond.ownerId }); + const used = await tx.roleGrant.count({ + where: { + pondId: pond.id, + role: role === 'editor' ? 'EDITOR' : 'READER', + scopeType: 'POND', + subjectType: 'USER', + effect: 'ALLOW', + ...(excludeUserId ? { subjectId: { not: excludeUserId } } : {}), + }, + }); + if (used >= limit) throw quotaExceeded(key, limit); + } + + private viewOf( + user: { id: string; username: string; displayName: string }, + role: MemberRole, + pond: Pond, + ): MemberView { + return { + userId: user.id, + username: user.username, + displayName: user.displayName, + role, + isOwner: user.id === pond.ownerId, + }; + } + + private async accessChanged(pondId: string): Promise { + this.permissionCache.invalidate(pondId); + await this.accessNotifier.notifyAccessChanged(pondId); + } +} diff --git a/apps/web/e2e/members.spec.ts b/apps/web/e2e/members.spec.ts new file mode 100644 index 0000000..179896d --- /dev/null +++ b/apps/web/e2e/members.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from '@playwright/test'; +import type { BrowserContext } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** + * Pond member management UI (issue #54): adding takes effect immediately (no + * invitation), seat quotas disable the add action, and non-admin members see + * the list read-only. Uses a second regular account (`fixture-editor`) as the + * member whose access the owner (`fixture-user`) manages. + */ + +async function createSharedPond(owner: BrowserContext): Promise<{ id: string; slug: string }> { + const res = await owner.request.post('/api/v1/ponds', { + data: { name: `Members ${Date.now()}` }, + }); + if (!res.ok()) throw new Error(`create pond failed: ${res.status()} ${await res.text()}`); + return (await res.json()) as { id: string; slug: string }; +} + +/** Site-admin only: change the instance-wide editor seat default. */ +async function setEditorQuota(admin: BrowserContext, value: number): Promise { + const res = await admin.request.patch('/api/v1/admin/settings', { + data: { 'quota.editorsPerPond': value }, + }); + if (!res.ok()) throw new Error(`patch settings failed: ${res.status()} ${await res.text()}`); +} + +test('adding a member takes effect immediately (second browser)', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const invitee = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const pond = await createSharedPond(owner); + + // Not a member yet: the pond hides itself from the invitee. + expect((await invitee.request.get(`/api/v1/ponds/${pond.slug}`)).status()).toBe(404); + + const page = await owner.newPage(); + await page.goto(`/p/${pond.slug}/settings`); + await page.locator('.member-add__identifier').fill('fixture-editor'); + await page.locator('.member-add__role').selectOption('reader'); + await page.locator('.member-add__submit').click(); + await expect(page.locator('.member-row[data-username="fixture-editor"]')).toBeVisible(); + + // Access is immediate for the second browser — no acceptance step. + await expect + .poll(async () => (await invitee.request.get(`/api/v1/ponds/${pond.slug}`)).status()) + .toBe(200); + + await owner.close(); + await invitee.close(); +}); + +test('quota exhaustion disables the add action with an explanation', async ({ browser }) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + try { + await setEditorQuota(admin, 1); + const pond = await createSharedPond(owner); + + const page = await owner.newPage(); + await page.goto(`/p/${pond.slug}/settings`); + // Fill the single editor seat. + await page.locator('.member-add__identifier').fill('fixture-editor'); + await page.locator('.member-add__role').selectOption('editor'); + await page.locator('.member-add__submit').click(); + await expect(page.locator('.member-row[data-username="fixture-editor"]')).toBeVisible(); + + // With the seat full, choosing the editor role blocks any further add. + await page.locator('.member-add__role').selectOption('editor'); + await expect(page.locator('.member-add__submit')).toBeDisabled(); + await expect(page.locator('.member-add__quota-full')).toBeVisible(); + } finally { + await setEditorQuota(admin, 5); + await admin.close(); + await owner.close(); + } +}); + +test('non-admin members see the member list read-only', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const member = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const pond = await createSharedPond(owner); + await owner.request.post(`/api/v1/ponds/${pond.id}/members`, { + data: { usernameOrEmail: 'fixture-editor', role: 'reader' }, + }); + + const page = await member.newPage(); + await page.goto(`/p/${pond.slug}/settings`); + // The membership is transparent — the owner is listed… + await expect(page.locator('.member-row[data-username="fixture-user"]')).toBeVisible(); + // …but a non-admin gets no add form and no per-member controls. + await expect(page.locator('.member-add')).toHaveCount(0); + await expect(page.locator('.member-row__remove')).toHaveCount(0); + await expect(page.locator('.member-manager__note')).toBeVisible(); + + await owner.close(); + await member.close(); +}); diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index e2d4eb7..123fe96 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -4,6 +4,7 @@ import deEditor from '@dorfteich/shared/i18n/de/editor.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deLabels from '@dorfteich/shared/i18n/de/labels.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json'; +import deMembers from '@dorfteich/shared/i18n/de/members.json'; import deSearch from '@dorfteich/shared/i18n/de/search.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import enAuth from '@dorfteich/shared/i18n/en/auth.json'; @@ -12,6 +13,7 @@ import enEditor from '@dorfteich/shared/i18n/en/editor.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enLabels from '@dorfteich/shared/i18n/en/labels.json'; import enLinks from '@dorfteich/shared/i18n/en/links.json'; +import enMembers from '@dorfteich/shared/i18n/en/members.json'; import enSearch from '@dorfteich/shared/i18n/en/search.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import i18n from 'i18next'; @@ -37,6 +39,7 @@ void i18n editor: enEditor, labels: enLabels, links: enLinks, + members: enMembers, search: enSearch, }, de: { @@ -47,6 +50,7 @@ void i18n editor: deEditor, labels: deLabels, links: deLinks, + members: deMembers, search: deSearch, }, }, diff --git a/apps/web/src/members/MemberManager.tsx b/apps/web/src/members/MemberManager.tsx new file mode 100644 index 0000000..c93b2e7 --- /dev/null +++ b/apps/web/src/members/MemberManager.tsx @@ -0,0 +1,243 @@ +import type { MemberRole, MemberView, SeatedMemberRole } from '@dorfteich/shared'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ApiError } from '../lib/api'; +import { useMemberMutations, usePondMembers } from './use-pond-members'; + +/** Turns an ApiError code into a translated message; other errors are generic. */ +function useErrorText(): (error: unknown) => string { + const { t } = useTranslation('errors'); + return (error) => { + if (error instanceof ApiError) { + return t(error.body.code, { + defaultValue: error.body.message, + ...(error.body.details ?? {}), + }); + } + return t('internal_error'); + }; +} + +const ROLE_ORDER: MemberRole[] = ['pond_admin', 'editor', 'reader']; +const SEATED_ROLES: SeatedMemberRole[] = ['editor', 'reader']; + +/** + * Pond-settings member management (issue #54). Lists members grouped by role + * with a search filter and seat usage; Pond Admins can add by exact username or + * e-mail (no directory browsing — privacy), change roles, and remove. Adding is + * immediate (there is no invitation to accept). Non-admins see the list + * read-only. The api enforces every rule; this UI only reflects it. + */ +export function MemberManager({ pondId }: { pondId: string }): React.JSX.Element { + const { t } = useTranslation('members'); + const query = usePondMembers(pondId); + const mutations = useMemberMutations(pondId); + const errorText = useErrorText(); + + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [identifier, setIdentifier] = useState(''); + const [addRole, setAddRole] = useState('reader'); + + const view = query.data; + const filtered = useMemo(() => { + const needle = search.trim().toLowerCase(); + const members = view?.members ?? []; + if (!needle) return members; + return members.filter( + (m) => + m.displayName.toLowerCase().includes(needle) || m.username.toLowerCase().includes(needle), + ); + }, [view?.members, search]); + + if (query.isLoading) return

; + if (query.error || !view) { + return ( +

+ {t('loadError')} +

+ ); + } + + const { seats, pondType, canManage } = view; + // Personal ponds have a single administrator (their owner) — no admin option. + const addableRoles: MemberRole[] = + pondType === 'personal' ? ['editor', 'reader'] : ['pond_admin', 'editor', 'reader']; + const seatFull = (role: MemberRole): boolean => + (role === 'editor' || role === 'reader') && seats[role].used >= seats[role].limit; + const addBlockedByQuota = seatFull(addRole); + + const run = async (action: () => Promise): Promise => { + setError(null); + try { + await action(); + } catch (err) { + setError(errorText(err)); + } + }; + + const submitAdd = async (event: React.FormEvent): Promise => { + event.preventDefault(); + const usernameOrEmail = identifier.trim(); + if (!usernameOrEmail || addBlockedByQuota) return; + await run(async () => { + await mutations.add({ usernameOrEmail, role: addRole }); + setIdentifier(''); + }); + }; + + const roleLabel = (role: MemberRole): string => t(`roles.${role}`); + + return ( +
+

{t('description')}

+ +
    + {SEATED_ROLES.map((role) => ( +
  • + {t(`seats.${role}`, { used: seats[role].used, limit: seats[role].limit })} +
  • + ))} +
+ + {pondType === 'personal' && ( +

+ {t('personalPondNote')} +

+ )} + + {error && ( +

+ {error} +

+ )} + + {canManage ? ( +
+ + + + {addBlockedByQuota && ( +

+ {t('add.quotaFull', { role: roleLabel(addRole) })} +

+ )} +
+ ) : ( +

+ {t('readOnlyNote')} +

+ )} + + setSearch(e.target.value)} + /> + + {filtered.length === 0 ? ( +

+ {view.members.length === 0 ? t('empty') : t('search.empty')} +

+ ) : ( + ROLE_ORDER.filter((role) => filtered.some((m) => m.role === role)).map((role) => ( +
+

{t(`groups.${role}`)}

+
    + {filtered + .filter((m) => m.role === role) + .map((member) => ( + run(() => mutations.changeRole(member.userId, next))} + onRemove={() => run(() => mutations.remove(member.userId))} + /> + ))} +
+
+ )) + )} +
+ ); +} + +function MemberRow({ + member, + canManage, + roleLabel, + onChangeRole, + onRemove, +}: { + member: MemberView; + canManage: boolean; + roleLabel: (role: MemberRole) => string; + onChangeRole: (role: MemberRole) => void; + onRemove: () => void; +}): React.JSX.Element { + const { t } = useTranslation('members'); + // The owner's own membership is fixed; everyone else is manageable by admins. + const manageable = canManage && !member.isOwner; + + return ( +
  • + + {member.displayName} + @{member.username} + {member.isOwner && · {t('ownerBadge')}} + + {manageable ? ( + + + + + ) : ( + {roleLabel(member.role)} + )} +
  • + ); +} diff --git a/apps/web/src/members/use-pond-members.ts b/apps/web/src/members/use-pond-members.ts new file mode 100644 index 0000000..43ce7e0 --- /dev/null +++ b/apps/web/src/members/use-pond-members.ts @@ -0,0 +1,42 @@ +import type { AddMemberInput, MemberRole, PondMembersView } from '@dorfteich/shared'; +import { useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; + +import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; + +/** Query key for a pond's member list. */ +export const membersKey = (pondId: string): string[] => ['members', pondId]; + +/** The pond's members, seat usage, and whether the caller may manage (issue #54). */ +export function usePondMembers(pondId: string | undefined): UseQueryResult { + return useQuery({ + queryKey: membersKey(pondId ?? ''), + queryFn: () => apiGet(`/ponds/${pondId}/members`), + enabled: Boolean(pondId), + }); +} + +/** Member mutations for one pond; each refreshes the member list. */ +export function useMemberMutations(pondId: string): { + add: (input: AddMemberInput) => Promise; + changeRole: (userId: string, role: MemberRole) => Promise; + remove: (userId: string) => Promise; +} { + const queryClient = useQueryClient(); + const invalidate = (): Promise => + queryClient.invalidateQueries({ queryKey: membersKey(pondId) }); + + return { + add: async (input) => { + await apiPost(`/ponds/${pondId}/members`, input); + await invalidate(); + }, + changeRole: async (userId, role) => { + await apiPatch(`/ponds/${pondId}/members/${userId}`, { role }); + await invalidate(); + }, + remove: async (userId) => { + await apiDelete(`/ponds/${pondId}/members/${userId}`); + await invalidate(); + }, + }; +} diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index a69b5bf..b236d46 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -8,16 +8,20 @@ import { FormError } from '../components/forms'; import { LabelManager } from '../labels/LabelManager'; import { PhantomPagesView } from '../links/PhantomPagesView'; import { apiGet } from '../lib/api'; +import { MemberManager } from '../members/MemberManager'; /** - * Pond settings (issue #44). Currently hosts the 'Labels' section; future - * pond-level configuration (fonts, etc.) joins it here. Label management needs - * modify rights — the manager's api calls enforce it, and the sidebar only - * links here for the pond owner (Site Admins can still navigate directly). + * Pond settings (issues #44/#54). Hosts the 'Members' and 'Labels' sections; + * future pond-level configuration (fonts, etc.) joins it here. Member + * management is Pond-Admin-gated in the api (the MemberManager shows the list + * to any member and hides the controls otherwise); label management needs + * modify rights. The sidebar links here for the pond owner (Site Admins and + * other members can still navigate directly). */ export function PondSettingsPage(): React.JSX.Element { const { t } = useTranslation('labels'); const { t: tLinks } = useTranslation('links'); + const { t: tMembers } = useTranslation('members'); const { t: tErrors } = useTranslation('errors'); const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { user } = useAuth(); @@ -35,6 +39,10 @@ export function PondSettingsPage(): React.JSX.Element { return (

    {pond.data.name}

    +
    +

    {tMembers('title')}

    + +

    {t('settings.title')}

    {canModify ? ( diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index bbaa082..5dc2eec 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1009,6 +1009,111 @@ button { max-width: 48rem; } +/* Member management (issue #54) */ +.member-manager__intro, +.member-manager__note, +.member-manager__empty { + color: var(--color-text-muted); + margin-bottom: var(--space-3); +} + +.member-seats { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin: 0 0 var(--space-3); + padding: 0; +} + +.member-seats__item { + background: var(--color-surface-muted, rgba(127, 127, 127, 0.12)); + border-radius: var(--radius-sm, 0.375rem); + padding: var(--space-1) var(--space-2); + font-size: 0.875rem; +} + +.member-add { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.member-add__field { + display: flex; + flex-direction: column; + gap: var(--space-1); + font-size: 0.875rem; +} + +.member-add__quota-full { + flex-basis: 100%; + color: var(--color-danger); + margin: 0; + font-size: 0.875rem; +} + +.member-search { + width: 100%; + margin-bottom: var(--space-3); +} + +.member-group { + margin-bottom: var(--space-4); +} + +.member-group__title { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + margin: 0 0 var(--space-2); +} + +.member-group__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.member-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2); + border: 1px solid var(--color-border, rgba(127, 127, 127, 0.25)); + border-radius: var(--radius-sm, 0.375rem); +} + +.member-row__username, +.member-row__owner-badge { + color: var(--color-text-muted); +} + +.member-row__actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .label-manager__description { color: var(--color-text-muted); margin-bottom: var(--space-3); diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index b9209b6..b652f0b 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -39,6 +39,10 @@ "grant_scope_not_found": "Das Label oder die Seite, auf die sich diese Berechtigung bezieht, existiert in diesem Teich nicht.", "grant_subject_not_found": "Diese Person existiert nicht.", "grant_last_admin": "Die letzte Teich-Admin-Berechtigung kann nicht entfernt werden.", + "member_not_found": "Es existiert keine Person mit diesem Benutzernamen oder dieser E-Mail.", + "member_exists": "Diese Person ist bereits Mitglied dieses Teichs.", + "member_not_a_member": "Diese Person ist kein Mitglied dieses Teichs.", + "member_is_owner": "Die Mitgliedschaft des Teich-Eigentümers kann hier nicht geändert werden.", "validation": { "required": "Dieses Feld ist erforderlich.", "taken": "Dieser Wert ist bereits vergeben.", diff --git a/packages/shared/i18n/de/members.json b/packages/shared/i18n/de/members.json new file mode 100644 index 0000000..c960048 --- /dev/null +++ b/packages/shared/i18n/de/members.json @@ -0,0 +1,40 @@ +{ + "title": "Mitglieder", + "description": "Personen mit Zugriff auf diesen Teich. Wer hinzugefügt wird, hat sofort Zugriff — es gibt keine Einladung zum Annehmen.", + "roles": { + "pond_admin": "Admin", + "editor": "Bearbeiter", + "reader": "Leser" + }, + "groups": { + "pond_admin": "Admins", + "editor": "Bearbeiter", + "reader": "Leser" + }, + "ownerBadge": "Eigentümer", + "search": { + "placeholder": "Mitglieder durchsuchen …", + "empty": "Keine Mitglieder passen zur Suche." + }, + "seats": { + "editor": "{{used}} von {{limit}} Bearbeiter-Plätzen", + "reader": "{{used}} von {{limit}} Leser-Plätzen" + }, + "add": { + "title": "Mitglied hinzufügen", + "identifier": "Benutzername oder E-Mail", + "identifierPlaceholder": "Benutzername oder E-Mail", + "role": "Rolle", + "submit": "Hinzufügen", + "quotaFull": "Keine {{role}}-Plätze mehr frei. Entferne ein Mitglied oder bitte einen Site-Admin, das Kontingent zu erhöhen." + }, + "actions": { + "role": "Rolle", + "remove": "Entfernen", + "removeConfirm": "{{name}} aus diesem Teich entfernen?" + }, + "personalPondNote": "Dies ist ein persönlicher Teich: nur der Eigentümer verwaltet ihn, ein zweiter Administrator kann nicht hinzugefügt werden.", + "empty": "Dieser Teich hat noch keine Mitglieder.", + "readOnlyNote": "Nur Teich-Admins können Mitglieder hinzufügen oder entfernen.", + "loadError": "Mitglieder konnten nicht geladen werden." +} diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index fe22125..373dc89 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -39,6 +39,10 @@ "grant_scope_not_found": "The label or page this grant points to does not exist in this pond.", "grant_subject_not_found": "This user does not exist.", "grant_last_admin": "The last Pond Admin cannot be removed.", + "member_not_found": "No user with that username or e-mail exists.", + "member_exists": "This user is already a member of this pond.", + "member_not_a_member": "This user is not a member of this pond.", + "member_is_owner": "The pond owner's membership cannot be changed here.", "validation": { "required": "This field is required.", "taken": "This value is already taken.", diff --git a/packages/shared/i18n/en/members.json b/packages/shared/i18n/en/members.json new file mode 100644 index 0000000..78916d6 --- /dev/null +++ b/packages/shared/i18n/en/members.json @@ -0,0 +1,40 @@ +{ + "title": "Members", + "description": "People who can access this pond. Adding someone takes effect immediately — there is no invitation to accept.", + "roles": { + "pond_admin": "Admin", + "editor": "Editor", + "reader": "Reader" + }, + "groups": { + "pond_admin": "Admins", + "editor": "Editors", + "reader": "Readers" + }, + "ownerBadge": "Owner", + "search": { + "placeholder": "Search members…", + "empty": "No members match your search." + }, + "seats": { + "editor": "{{used}} of {{limit}} editor seats", + "reader": "{{used}} of {{limit}} reader seats" + }, + "add": { + "title": "Add member", + "identifier": "Username or e-mail", + "identifierPlaceholder": "username or e-mail", + "role": "Role", + "submit": "Add", + "quotaFull": "No {{role}} seats left. Remove a member or ask a Site Admin to raise the quota." + }, + "actions": { + "role": "Role", + "remove": "Remove", + "removeConfirm": "Remove {{name}} from this pond?" + }, + "personalPondNote": "This is a personal pond: only its owner administers it, and no second administrator can be added.", + "empty": "This pond has no members yet.", + "readOnlyNote": "Only Pond Admins can add or remove members.", + "loadError": "Members could not be loaded." +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c793eb4..7a1c75f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export * from './health'; export * from './i18n-tools'; export * from './labels'; export * from './links'; +export * from './members'; export * from './pages'; export * from './permissions'; export * from './search'; diff --git a/packages/shared/src/members.ts b/packages/shared/src/members.ts new file mode 100644 index 0000000..7299d5c --- /dev/null +++ b/packages/shared/src/members.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +import { GRANT_ROLES } from './permissions/schemas'; + +/** + * Pond membership shared between api and web (issue #54). A "member" is a user + * with a pond-scope allow grant (permissions.md §roles); the Members UI manages + * these directly by role, on top of the general grant model (#51/#52). Only + * pond-scope user grants are members — label/page-scope grants (#55) and the + * `authenticated`/`public` subjects are managed elsewhere. + */ + +/** The role a member holds on a pond, in descending order of capability. */ +export type MemberRole = (typeof GRANT_ROLES)[number]; // 'pond_admin' | 'editor' | 'reader' + +/** Roles that consume a numbered seat quota (issue #22). Admins are unlimited. */ +export const SEATED_MEMBER_ROLES = ['editor', 'reader'] as const; +export type SeatedMemberRole = (typeof SEATED_MEMBER_ROLES)[number]; + +/** One pond member as shown in the management list. */ +export interface MemberView { + userId: string; + username: string; + displayName: string; + role: MemberRole; + /** The pond owner; their membership cannot be changed or removed here. */ + isOwner: boolean; +} + +/** Seat usage for a quota-limited role, for the "3 of 5 editor seats" display. */ +export interface SeatUsage { + used: number; + limit: number; +} + +/** Response of `GET /ponds/:id/members`. */ +export interface PondMembersView { + members: MemberView[]; + seats: Record; + pondType: 'personal' | 'shared'; + /** Whether the requesting user may manage members (Pond Admin). */ + canManage: boolean; +} + +/** Non-empty username or e-mail; the server resolves it to exactly one user. */ +const memberIdentifierSchema = z.string().trim().min(1, 'validation.required').max(320); + +export const addMemberInputSchema = z.object({ + usernameOrEmail: memberIdentifierSchema, + role: z.enum(GRANT_ROLES), +}); +export type AddMemberInput = z.infer; + +export const changeMemberRoleInputSchema = z.object({ + role: z.enum(GRANT_ROLES), +}); +export type ChangeMemberRoleInput = z.infer;