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

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:
Claude Opus 4.8 2026-07-09 20:22:52 +02:00
parent 9d288b2ad0
commit 7f1c49db53
18 changed files with 1303 additions and 4 deletions

View File

@ -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%';" | \

View File

@ -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({

View 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);
}
}

View 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);
});
});

View 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 {}

View 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);
}
}

View 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();
});

View File

@ -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,
},
},

View 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>
);
}

View 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();
},
};
}

View File

@ -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 ? (

View File

@ -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);

View File

@ -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.",

View 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."
}

View File

@ -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.",

View 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."
}

View File

@ -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';

View 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>;