Add pond member management UI (#54)
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m29s
CI / Auth e2e pack (push) Successful in 3m8s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 12s
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m29s
CI / Auth e2e pack (push) Successful in 3m8s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 12s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
9d288b2ad0
commit
7f1c49db53
@ -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%';" | \
|
||||
|
||||
@ -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({
|
||||
|
||||
65
apps/api/src/members/members.controller.ts
Normal file
65
apps/api/src/members/members.controller.ts
Normal file
@ -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<PondMembersView> {
|
||||
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<MemberView> {
|
||||
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<MemberView> {
|
||||
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<void> {
|
||||
await this.members.remove(request.user!, pondId, userId);
|
||||
}
|
||||
}
|
||||
251
apps/api/src/members/members.e2e.db.test.ts
Normal file
251
apps/api/src/members/members.e2e.db.test.ts
Normal file
@ -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<string, string> = {};
|
||||
const emails: Record<string, string> = {};
|
||||
const cookies: Record<string, string> = {};
|
||||
|
||||
let pondId: string;
|
||||
let personalPondId: string;
|
||||
|
||||
const api = () => request(app.getHttpServer());
|
||||
|
||||
async function makeUser(handle: string): Promise<void> {
|
||||
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<PondMembersView> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
21
apps/api/src/members/members.module.ts
Normal file
21
apps/api/src/members/members.module.ts
Normal file
@ -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 {}
|
||||
301
apps/api/src/members/members.service.ts
Normal file
301
apps/api/src/members/members.service.ts
Normal file
@ -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<MemberRole, number> = { 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<Pond> {
|
||||
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<PondMembersView> {
|
||||
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<string, MemberRole>();
|
||||
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<MemberView> {
|
||||
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<MemberView> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// ::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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.permissionCache.invalidate(pondId);
|
||||
await this.accessNotifier.notifyAccessChanged(pondId);
|
||||
}
|
||||
}
|
||||
100
apps/web/e2e/members.spec.ts
Normal file
100
apps/web/e2e/members.spec.ts
Normal file
@ -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<void> {
|
||||
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();
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
243
apps/web/src/members/MemberManager.tsx
Normal file
243
apps/web/src/members/MemberManager.tsx
Normal file
@ -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<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [addRole, setAddRole] = useState<MemberRole>('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 <p aria-busy="true" />;
|
||||
if (query.error || !view) {
|
||||
return (
|
||||
<p className="form-banner form-banner--error" role="alert">
|
||||
{t('loadError')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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<void>): Promise<void> => {
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
}
|
||||
};
|
||||
|
||||
const submitAdd = async (event: React.FormEvent): Promise<void> => {
|
||||
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 (
|
||||
<div className="member-manager">
|
||||
<p className="member-manager__intro">{t('description')}</p>
|
||||
|
||||
<ul className="member-seats">
|
||||
{SEATED_ROLES.map((role) => (
|
||||
<li key={role} className={`member-seats__item member-seats__item--${role}`}>
|
||||
{t(`seats.${role}`, { used: seats[role].used, limit: seats[role].limit })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{pondType === 'personal' && (
|
||||
<p className="member-manager__note" role="note">
|
||||
{t('personalPondNote')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="form-banner form-banner--error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
<form className="member-add" onSubmit={submitAdd}>
|
||||
<label className="member-add__field">
|
||||
<span>{t('add.identifier')}</span>
|
||||
<input
|
||||
className="member-add__identifier"
|
||||
value={identifier}
|
||||
placeholder={t('add.identifierPlaceholder')}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label className="member-add__field">
|
||||
<span>{t('add.role')}</span>
|
||||
<select
|
||||
className="member-add__role"
|
||||
value={addRole}
|
||||
onChange={(e) => setAddRole(e.target.value as MemberRole)}
|
||||
>
|
||||
{addableRoles.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{roleLabel(role)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button className="button member-add__submit" type="submit" disabled={addBlockedByQuota}>
|
||||
{t('add.submit')}
|
||||
</button>
|
||||
{addBlockedByQuota && (
|
||||
<p className="member-add__quota-full" role="note">
|
||||
{t('add.quotaFull', { role: roleLabel(addRole) })}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<p className="member-manager__note" role="note">
|
||||
{t('readOnlyNote')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
className="member-search"
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder={t('search.placeholder')}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="member-manager__empty">
|
||||
{view.members.length === 0 ? t('empty') : t('search.empty')}
|
||||
</p>
|
||||
) : (
|
||||
ROLE_ORDER.filter((role) => filtered.some((m) => m.role === role)).map((role) => (
|
||||
<section key={role} className="member-group">
|
||||
<h3 className="member-group__title">{t(`groups.${role}`)}</h3>
|
||||
<ul className="member-group__list">
|
||||
{filtered
|
||||
.filter((m) => m.role === role)
|
||||
.map((member) => (
|
||||
<MemberRow
|
||||
key={member.userId}
|
||||
member={member}
|
||||
canManage={canManage}
|
||||
roleLabel={roleLabel}
|
||||
onChangeRole={(next) => run(() => mutations.changeRole(member.userId, next))}
|
||||
onRemove={() => run(() => mutations.remove(member.userId))}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<li className="member-row" data-username={member.username}>
|
||||
<span className="member-row__name">
|
||||
{member.displayName}
|
||||
<span className="member-row__username"> @{member.username}</span>
|
||||
{member.isOwner && <span className="member-row__owner-badge"> · {t('ownerBadge')}</span>}
|
||||
</span>
|
||||
{manageable ? (
|
||||
<span className="member-row__actions">
|
||||
<label className="member-row__role-field">
|
||||
<span className="visually-hidden">{t('actions.role')}</span>
|
||||
<select
|
||||
className="member-row__role"
|
||||
value={member.role}
|
||||
onChange={(e) => onChangeRole(e.target.value as MemberRole)}
|
||||
>
|
||||
{ROLE_ORDER.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{roleLabel(role)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="button member-row__remove" onClick={onRemove}>
|
||||
{t('actions.remove')}
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="member-row__role-label">{roleLabel(member.role)}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
42
apps/web/src/members/use-pond-members.ts
Normal file
42
apps/web/src/members/use-pond-members.ts
Normal file
@ -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<PondMembersView> {
|
||||
return useQuery({
|
||||
queryKey: membersKey(pondId ?? ''),
|
||||
queryFn: () => apiGet<PondMembersView>(`/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<void>;
|
||||
changeRole: (userId: string, role: MemberRole) => Promise<void>;
|
||||
remove: (userId: string) => Promise<void>;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = (): Promise<void> =>
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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 (
|
||||
<div className="pond-settings-page">
|
||||
<h1>{pond.data.name}</h1>
|
||||
<section>
|
||||
<h2>{tMembers('title')}</h2>
|
||||
<MemberManager pondId={pond.data.id} />
|
||||
</section>
|
||||
<section>
|
||||
<h2>{t('settings.title')}</h2>
|
||||
{canModify ? (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.",
|
||||
|
||||
40
packages/shared/i18n/de/members.json
Normal file
40
packages/shared/i18n/de/members.json
Normal file
@ -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."
|
||||
}
|
||||
@ -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.",
|
||||
|
||||
40
packages/shared/i18n/en/members.json
Normal file
40
packages/shared/i18n/en/members.json
Normal file
@ -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."
|
||||
}
|
||||
@ -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';
|
||||
|
||||
57
packages/shared/src/members.ts
Normal file
57
packages/shared/src/members.ts
Normal file
@ -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<SeatedMemberRole, SeatUsage>;
|
||||
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<typeof addMemberInputSchema>;
|
||||
|
||||
export const changeMemberRoleInputSchema = z.object({
|
||||
role: z.enum(GRANT_ROLES),
|
||||
});
|
||||
export type ChangeMemberRoleInput = z.infer<typeof changeMemberRoleInputSchema>;
|
||||
Loading…
Reference in New Issue
Block a user