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
58 lines
2.0 KiB
TypeScript
58 lines
2.0 KiB
TypeScript
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>;
|